From 036b7523a53c8500e5519a722782a279aa361043 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:28:16 +0300 Subject: [PATCH 001/107] xftp: matrix test for new versions (#1093) * xftp: matrix test for new versions * manual matrix * more, faster --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/FileTransfer/Client.hs | 16 ++++--- src/Simplex/FileTransfer/Protocol.hs | 5 +-- src/Simplex/FileTransfer/Server/Env.hs | 19 ++++---- src/Simplex/FileTransfer/Transport.hs | 5 ++- src/Simplex/Messaging/Transport/Server.hs | 8 ++-- tests/XFTPAgent.hs | 55 ++++++++++++++++++----- tests/XFTPClient.hs | 18 +++++--- 7 files changed, 88 insertions(+), 38 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 642a3e73b..fcb54aece 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -28,6 +28,7 @@ import qualified Data.X509.Validation as XV import qualified Network.HTTP.Types as N import qualified Network.HTTP2.Client as H import Simplex.FileTransfer.Protocol +import Simplex.FileTransfer.Server.Env (supportedXFTPhandshakes) import Simplex.FileTransfer.Transport import Simplex.Messaging.Client ( NetworkConfig (..), @@ -50,7 +51,7 @@ import Simplex.Messaging.Protocol RecipientId, SenderId, ) -import Simplex.Messaging.Transport (HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), supportedParameters) +import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), supportedParameters) import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, alpn) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client @@ -69,7 +70,8 @@ data XFTPClient = XFTPClient data XFTPClientConfig = XFTPClientConfig { xftpNetworkConfig :: NetworkConfig, - serverVRange :: VersionRangeXFTP + serverVRange :: VersionRangeXFTP, + clientALPN :: Maybe [ALPN] } data XFTPChunkBody = XFTPChunkBody @@ -91,12 +93,13 @@ defaultXFTPClientConfig :: XFTPClientConfig defaultXFTPClientConfig = XFTPClientConfig { xftpNetworkConfig = defaultNetworkConfig, - serverVRange = supportedFileServerVRange + serverVRange = supportedFileServerVRange, + clientALPN = Just supportedXFTPhandshakes } getXFTPClient :: TVar ChaChaDRG -> TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) -getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do - let tcConfig = (transportClientConfig xftpNetworkConfig) {alpn = Just ["xftp/1"]} +getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do + let tcConfig = (transportClientConfig xftpNetworkConfig) {alpn = clientALPN} http2Config = xftpHTTP2Config tcConfig config username = proxyUsername transportSession ProtocolServer _ host port keyHash = srv @@ -108,10 +111,11 @@ getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {xftpNetwor let HTTP2Client {sessionId, sessionALPN} = http2Client thParams0 = THandleParams {sessionId, blockSize = xftpBlockSize, thVersion = VersionXFTP 1, thAuth = Nothing, implySessId = False, batch = True} logDebug $ "Client negotiated handshake protocol: " <> tshow sessionALPN - thParams <- case sessionALPN of + thParams@THandleParams {thVersion} <- case sessionALPN of Just "xftp/1" -> xftpClientHandshakeV1 g serverVRange keyHash http2Client thParams0 Nothing -> pure thParams0 _ -> throwError $ PCETransportError (TEHandshake VERSION) + logDebug $ "Client negotiated protocol: " <> tshow thVersion let c = XFTPClient {http2Client, thParams, transportSession, config} atomically $ writeTVar clientVar $ Just c pure c diff --git a/src/Simplex/FileTransfer/Protocol.hs b/src/Simplex/FileTransfer/Protocol.hs index dcd9f2c52..f970fdfcc 100644 --- a/src/Simplex/FileTransfer/Protocol.hs +++ b/src/Simplex/FileTransfer/Protocol.hs @@ -25,7 +25,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import Data.Maybe (isNothing) import Data.Type.Equality import Data.Word (Word32) -import Simplex.FileTransfer.Transport (VersionXFTP, XFTPErrorType (..), XFTPVersion, xftpClientHandshakeStub, pattern VersionXFTP) +import Simplex.FileTransfer.Transport (XFTPErrorType (..), XFTPVersion, xftpClientHandshakeStub) import Simplex.Messaging.Client (authTransmission) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding @@ -58,9 +58,6 @@ import Simplex.Messaging.Protocol import Simplex.Messaging.Transport (THandleParams (..), TransportError (..)) import Simplex.Messaging.Util ((<$?>)) -currentXFTPVersion :: VersionXFTP -currentXFTPVersion = VersionXFTP 1 - xftpBlockSize :: Int xftpBlockSize = 16384 diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index 789a3dc20..b7f60c9af 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -32,7 +32,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (BasicAuth, RcvPublicAuthKey) import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Transport (ALPN) -import Simplex.Messaging.Transport.Server (TransportServerConfig, loadFingerprint, loadTLSServerParams) +import Simplex.Messaging.Transport.Server (TransportServerConfig (..), loadFingerprint, loadTLSServerParams) import Simplex.Messaging.Util (tshow) import System.IO (IOMode (..)) import UnliftIO.STM @@ -113,13 +113,16 @@ newXFTPServerEnv config@XFTPServerConfig {storeLogFile, fileSizeQuota, caCertifi logInfo $ "Total / available storage: " <> tshow quota <> " / " <> tshow (quota - used) when (quota < used) $ logInfo "WARNING: storage quota is less than used storage, no files can be uploaded!" tlsServerParams' <- liftIO $ loadTLSServerParams caCertificateFile certificateFile privateKeyFile - let tlsServerParams = - tlsServerParams' - { T.serverHooks = - def - { T.onALPNClientSuggest = Just $ pure . fromMaybe "" . find (`elem` supportedXFTPhandshakes) - } - } + let TransportServerConfig {alpn} = transportConfig config + let tlsServerParams = case alpn of + Nothing -> tlsServerParams' + Just supported -> + tlsServerParams' + { T.serverHooks = + def + { T.onALPNClientSuggest = Just $ pure . fromMaybe "" . find (`elem` supported) + } + } Fingerprint fp <- liftIO $ loadFingerprint caCertificateFile serverStats <- atomically . newFileServerStats =<< liftIO getCurrentTime pure XFTPEnv {config, store, storeLog, random, tlsServerParams, serverIdentity = C.KeyHash fp, serverStats} diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index 49b809bf1..d8bb72f6b 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -82,8 +82,11 @@ type THandleParamsXFTP = THandleParams XFTPVersion initialXFTPVersion :: VersionXFTP initialXFTPVersion = VersionXFTP 1 +currentXFTPVersion :: VersionXFTP +currentXFTPVersion = VersionXFTP 2 + supportedFileServerVRange :: VersionRangeXFTP -supportedFileServerVRange = mkVersionRange initialXFTPVersion initialXFTPVersion +supportedFileServerVRange = mkVersionRange initialXFTPVersion currentXFTPVersion -- XFTP protocol does not support handshake xftpClientHandshakeStub :: c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c) diff --git a/src/Simplex/Messaging/Transport/Server.hs b/src/Simplex/Messaging/Transport/Server.hs index 542ebbb75..e7360b21b 100644 --- a/src/Simplex/Messaging/Transport/Server.hs +++ b/src/Simplex/Messaging/Transport/Server.hs @@ -49,7 +49,8 @@ import UnliftIO.STM data TransportServerConfig = TransportServerConfig { logTLSErrors :: Bool, tlsSetupTimeout :: Int, - transportTimeout :: Int + transportTimeout :: Int, + alpn :: Maybe [ALPN] } deriving (Eq, Show) @@ -58,7 +59,8 @@ defaultTransportServerConfig = TransportServerConfig { logTLSErrors = True, tlsSetupTimeout = 60000000, - transportTimeout = 40000000 + transportTimeout = 40000000, + alpn = Nothing } serverTransportConfig :: TransportServerConfig -> TransportConfig @@ -114,7 +116,7 @@ runTCPServerSocket (accepted, gracefullyClosed, clients) started getSocket serve forever . E.bracketOnError (accept sock) (close . fst) $ \(conn, _peer) -> do cId <- atomically $ stateTVar accepted $ \cId -> let cId' = cId + 1 in cId `seq` (cId', cId') let closeConn _ = do - atomically $ modifyTVar' clients $ IM.delete cId + atomically $ modifyTVar' clients $ IM.delete cId gracefulClose conn 5000 `catchAll_` pure () -- catchAll_ is needed here in case the connection was closed earlier atomically $ modifyTVar' gracefullyClosed (+1) tId <- mkWeakThreadId =<< server conn `forkFinally` closeConn diff --git a/tests/XFTPAgent.hs b/tests/XFTPAgent.hs index 2d865f140..88786bb40 100644 --- a/tests/XFTPAgent.hs +++ b/tests/XFTPAgent.hs @@ -20,12 +20,14 @@ import Data.Int (Int64) import Data.List (find, isSuffixOf) import Data.Maybe (fromJust) import SMPAgentClient (agentCfg, initAgentServers, testDB, testDB2, testDB3) -import Simplex.FileTransfer.Description (FileChunk (..), FileDescription (..), FileDescriptionURI (..), ValidFileDescription, fileDescriptionURI, mb, qrSizeLimit, pattern ValidFileDescription) +import Simplex.FileTransfer.Client (XFTPClientConfig (..)) +import Simplex.FileTransfer.Description (FileChunk (..), FileDescription (..), FileDescriptionURI (..), ValidFileDescription, fileDescriptionURI, kb, mb, qrSizeLimit, pattern ValidFileDescription) import Simplex.FileTransfer.Protocol (FileParty (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) import Simplex.FileTransfer.Transport (XFTPErrorType (AUTH)) import Simplex.Messaging.Agent (AgentClient, testProtocolServer, xftpDeleteRcvFile, xftpDeleteSndFileInternal, xftpDeleteSndFileRemote, xftpReceiveFile, xftpSendDescription, xftpSendFile, xftpStartWorkers) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, xftpCfg) import Simplex.Messaging.Agent.Protocol (ACommand (..), AgentErrorType (..), BrokerErrorType (..), RcvFileId, SndFileId, noAuthSrv) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs) @@ -48,6 +50,7 @@ xftpAgentTests = around_ testBracket . describe "agent XFTP API" $ do it "should send and receive with encrypted local files" testXFTPAgentSendReceiveEncrypted it "should send and receive large file with a redirect" testXFTPAgentSendReceiveRedirect it "should send and receive small file without a redirect" testXFTPAgentSendReceiveNoRedirect + describe "sending and receiving with version negotiation" testXFTPAgentSendReceiveMatrix it "should resume receiving file after restart" testXFTPAgentReceiveRestore it "should cleanup rcv tmp path after permanent error" testXFTPAgentReceiveCleanup it "should resume sending file after restart" testXFTPAgentSendRestore @@ -90,7 +93,7 @@ sfProgress c expected = loop 0 -- checks that progress increases till it reaches total checkProgress :: (HasCallStack, MonadIO m) => (Int64, Int64) -> (Int64, Int64) -> (Int64 -> m ()) -> m () checkProgress (prev, expected) (progress, total) loop - | total /= expected = error "total /= expected" + | total /= expected = liftIO (print total) >> error "total /= expected" | progress <= prev = error "progress <= prev" | progress > total = error "progress > total" | progress < total = loop progress @@ -233,6 +236,34 @@ testXFTPAgentSendReceiveNoRedirect = withXFTPServer $ do inBytes <- B.readFile filePathIn B.readFile out `shouldReturn` inBytes +testXFTPAgentSendReceiveMatrix :: Spec +testXFTPAgentSendReceiveMatrix = do + describe "old server" $ do + it "new clients" $ run oldServer newClient newClient + it "new sender, old recipient" $ run oldServer newClient newClient + it "old sender, new recipient" $ run oldServer oldClient newClient + it "old clients" $ run oldServer oldClient oldClient + describe "new server" $ do + it "new clients" $ run newServer newClient newClient + it "new sender, old recipient" $ run newServer newClient newClient + it "old sender, new recipient" $ run newServer oldClient newClient + it "old clients" $ run newServer oldClient oldClient + where + oldClient = agentCfg {xftpCfg = (xftpCfg agentCfg) {clientALPN = Nothing}} + newClient = agentCfg + oldServer = testXFTPServerConfig_ Nothing + newServer = testXFTPServerConfig + run :: HasCallStack => XFTPServerConfig -> AgentConfig -> AgentConfig -> IO () + run server sender receiver = + withXFTPServerCfg server $ \_t -> do + filePath <- createRandomFile_ (kb 319 :: Integer) "testfile" + rfd <- withAgent 1 sender initAgentServers testDB $ \sndr -> do + (sfId, _, rfd1, _) <- runRight $ testSendCF' sndr (CF.plain filePath) (kb 320) + rfd1 <$ xftpDeleteSndFileInternal sndr sfId + withAgent 2 receiver initAgentServers testDB2 $ \rcp -> do + rfId <- runRight $ testReceiveCF' rcp rfd Nothing filePath (kb 320) + xftpDeleteRcvFile rcp rfId + createRandomFile :: HasCallStack => IO FilePath createRandomFile = createRandomFile' "testfile" @@ -250,10 +281,13 @@ testSend :: HasCallStack => AgentClient -> FilePath -> ExceptT AgentErrorType IO testSend sndr = testSendCF sndr . CF.plain testSendCF :: HasCallStack => AgentClient -> CryptoFile -> ExceptT AgentErrorType IO (SndFileId, ValidFileDescription 'FSender, ValidFileDescription 'FRecipient, ValidFileDescription 'FRecipient) -testSendCF sndr file = do +testSendCF sndr file = testSendCF' sndr file $ mb 18 + +testSendCF' :: HasCallStack => AgentClient -> CryptoFile -> Int64 -> ExceptT AgentErrorType IO (SndFileId, ValidFileDescription 'FSender, ValidFileDescription 'FRecipient, ValidFileDescription 'FRecipient) +testSendCF' sndr file size = do xftpStartWorkers sndr (Just senderFiles) sfId <- xftpSendFile sndr 1 file 2 - sfProgress sndr $ mb 18 + sfProgress sndr size ("", sfId', SFDONE sndDescr [rfd1, rfd2]) <- sfGet sndr liftIO $ testNoRedundancy rfd1 liftIO $ testNoRedundancy rfd2 @@ -270,15 +304,15 @@ testReceive rcp rfd = testReceiveCF rcp rfd Nothing testReceiveCF :: HasCallStack => AgentClient -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> FilePath -> ExceptT AgentErrorType IO RcvFileId testReceiveCF rcp rfd cfArgs originalFilePath = do xftpStartWorkers rcp (Just recipientFiles) - testReceiveCF' rcp rfd cfArgs originalFilePath + testReceiveCF' rcp rfd cfArgs originalFilePath $ mb 18 testReceive' :: HasCallStack => AgentClient -> ValidFileDescription 'FRecipient -> FilePath -> ExceptT AgentErrorType IO RcvFileId -testReceive' rcp rfd = testReceiveCF' rcp rfd Nothing +testReceive' rcp rfd originalFilePath = testReceiveCF' rcp rfd Nothing originalFilePath $ mb 18 -testReceiveCF' :: HasCallStack => AgentClient -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> FilePath -> ExceptT AgentErrorType IO RcvFileId -testReceiveCF' rcp rfd cfArgs originalFilePath = do +testReceiveCF' :: HasCallStack => AgentClient -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> FilePath -> Int64 -> ExceptT AgentErrorType IO RcvFileId +testReceiveCF' rcp rfd cfArgs originalFilePath size = do rfId <- xftpReceiveFile rcp 1 rfd cfArgs - rfProgress rcp $ mb 18 + rfProgress rcp size ("", rfId', RFDONE path) <- rfGet rcp liftIO $ do rfId' `shouldBe` rfId @@ -494,7 +528,6 @@ testXFTPAgentDeleteRestore = withGlobalLogging logCfgNoLogs $ do runRight_ $ xftpStartWorkers sndr (Just senderFiles) xftpDeleteSndFileRemote sndr 1 sfId sndDescr timeout 300000 (get sndr) `shouldReturn` Nothing -- wait for worker attempt - threadDelay 300000 length <$> listDirectory xftpServerFiles `shouldReturn` 6 @@ -545,7 +578,7 @@ testXFTPAgentDeleteOnServer = withGlobalLogging logCfgNoLogs $ -- receive file 1 again rfId1 <- xftpReceiveFile rcp 1 rfd1_2 Nothing ("", rfId1', RFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- rfGet rcp - liftIO $ rfId1 `shouldBe` rfId1' + liftIO $ rfId1 `shouldBe` rfId1' -- receive file 2 testReceive' rcp rfd2 filePath2 diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index d42ee7d06..4e926f1fd 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -1,4 +1,5 @@ {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -12,9 +13,10 @@ import SMPClient (serverBracket) import Simplex.FileTransfer.Client import Simplex.FileTransfer.Description import Simplex.FileTransfer.Server (runXFTPServerBlocking) -import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration, defaultInactiveClientExpiration) +import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration, defaultInactiveClientExpiration, supportedXFTPhandshakes) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (XFTPServer) +import Simplex.Messaging.Transport (ALPN) import Simplex.Messaging.Transport.Server import Test.Hspec @@ -95,7 +97,10 @@ testXFTPStatsBackupFile :: FilePath testXFTPStatsBackupFile = "tests/tmp/xftp-server-stats.log" testXFTPServerConfig :: XFTPServerConfig -testXFTPServerConfig = +testXFTPServerConfig = testXFTPServerConfig_ (Just supportedXFTPhandshakes) + +testXFTPServerConfig_ :: Maybe [ALPN] -> XFTPServerConfig +testXFTPServerConfig_ alpn = XFTPServerConfig { xftpPort = xftpTestPort, controlPort = Nothing, @@ -118,15 +123,18 @@ testXFTPServerConfig = logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", serverStatsBackupFile = Nothing, - transportConfig = defaultTransportServerConfig + transportConfig = defaultTransportServerConfig {alpn} } testXFTPClientConfig :: XFTPClientConfig testXFTPClientConfig = defaultXFTPClientConfig testXFTPClient :: HasCallStack => (HasCallStack => XFTPClient -> IO a) -> IO a -testXFTPClient client = do +testXFTPClient = testXFTPClientWith testXFTPClientConfig + +testXFTPClientWith :: HasCallStack => XFTPClientConfig -> (HasCallStack => XFTPClient -> IO a) -> IO a +testXFTPClientWith cfg client = do g <- C.newRandom - getXFTPClient g (1, testXFTPServer, Nothing) testXFTPClientConfig (\_ -> pure ()) >>= \case + getXFTPClient g (1, testXFTPServer, Nothing) cfg (\_ -> pure ()) >>= \case Right c -> client c Left e -> error $ show e From 875ddd80d6d58f9308b6d18e745c47a983c1ae3c Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:04:29 +0300 Subject: [PATCH 002/107] SMP proxy: protocol (#954) * WIP: proxy-related types * test plan * buildable with stubs * add auth test * update protocol * fix * update rfc * update protocol/types * disable test --------- Co-authored-by: Evgeny Poberezkin --- notes-flow.txt | 23 +++++++ rfcs/2023-09-12-second-relays.md | 87 +++++++++++++------------ simplexmq.cabal | 1 + src/Simplex/Messaging/Crypto.hs | 2 + src/Simplex/Messaging/Protocol.hs | 80 ++++++++++++++++++++++- src/Simplex/Messaging/Server.hs | 23 +++++++ src/Simplex/Messaging/Server/Env/STM.hs | 45 ++++++++++--- src/Simplex/Messaging/Server/Main.hs | 3 +- src/Simplex/Messaging/Transport.hs | 4 ++ tests/SMPClient.hs | 12 +++- tests/SMPProxyTests.hs | 67 +++++++++++++++++++ tests/ServerTests.hs | 2 - tests/Test.hs | 2 + 13 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 notes-flow.txt create mode 100644 tests/SMPProxyTests.hs diff --git a/notes-flow.txt b/notes-flow.txt new file mode 100644 index 000000000..93f984509 --- /dev/null +++ b/notes-flow.txt @@ -0,0 +1,23 @@ +common: + corrId - random BS, used as CbNonce + entityId - p2r tlsUniq + +# setup +s->p: "proxy", uri, auth? + # unless connected + p->r: "p_handshake" + p<-r: "r_key", tls-signed dh pub +s<-r: "r_key", tls-signed dh pub # reply entityId contains tlsUniq + +# working +s ; generate random dh priv, make shared secret +s->p: s2r("forward", random dh pub, SEND command blob) + p->r: p2r("forward", random dh pub, s2r("forward", ...))) + r->c@ "msg", ... + p<-r: p2r("r_res", s2r("ok" / "error", error)) +s<-p@ s2r("ok" / "error", error) + +# expired + p<-r@ p2r("error", "key expired") +s<-p@ "error", "key expired" +s ; reconnect \ No newline at end of file diff --git a/rfcs/2023-09-12-second-relays.md b/rfcs/2023-09-12-second-relays.md index 3721ee721..a47eb7bde 100644 --- a/rfcs/2023-09-12-second-relays.md +++ b/rfcs/2023-09-12-second-relays.md @@ -2,9 +2,9 @@ ## Problem -SMP protocol relays are chosen and can be controlled by the message recipients. It means that the recipients can find out IP addresses of message senders by modifying SMP relay code (or by using proxies and timing correlation), unless the senders use VPN or some overlay network. Tor is an audequate solution in most cases to mitigate it, but it requires additional technical knowledge to install and configure (even installing Orbot on Android is seen as "complex" by many users), and reduces usability because of higher latency. +SMP protocol relays are chosen and can be controlled by the message recipients. It means that the recipients can find out IP addresses of message senders by modifying SMP relay code (or by using proxies and timing correlation), unless the senders use VPN or some overlay network. Tor is an adequate solution in most cases to mitigate it, but it requires additional technical knowledge to install and configure (even installing Orbot on Android is seen as "complex" by many users), and reduces usability because of higher latency. -The lack of in-built IP address protection is the main concern of many users, particularly given that most people do not realise that it is lacking by default - without transport protection SimpleX is not perceived as a "whole product". +The lack of in-built IP address protection is the main concern of many users, particularly given that most people do not realize that it is lacking by default - without transport protection SimpleX is not perceived as a "whole product". Similarly, XFTP protocol relays are chosen by senders, and they can be used to detect file recipients' IP addresses. @@ -43,7 +43,7 @@ Overall, this is not a viable or even appropriate option for the current stage. 3. SMP / XFTP proxy. -Introduce SMP and XFTP protocol extenstions to allow message senders and file recipients to delegate the tasks of sending messages and receiving files to the proxies, so that peer-chosen relays can only observe IP addresses of the proxies and not of the users. +Introduce SMP and XFTP protocol extensions to allow message senders and file recipients to delegate the tasks of sending messages and receiving files to the proxies, so that peer-chosen relays can only observe IP addresses of the proxies and not of the users. Pros: - no dependency on and lower latency than via Tor @@ -68,7 +68,7 @@ Below considers this design. 2. SMP proxy should not be able to observe queue addresses and their count on the destination relays. This requirement is not needed for XFTP proxies, as each file chunk is downloaded only once, so there is no need to hide its address. -3. There must be no identifiers and cyphertext in common in outgoing and incoming traffic inside TLS (the current designs have this quality). +3. There must be no identifiers and ciphertext in common in outgoing and incoming traffic inside TLS (the current designs have this quality). 4. Traffic between the client and destination relays must be e2e encrypted, with MITM-by-proxy mitigated, relying on the relay identity (certificate fingerprint), ideally without any additional fingerprint in relay address. @@ -97,11 +97,11 @@ This would also reduce the difference in how the traffic looks to the observer - The flow of the messages will be: -1. Client requests proxy to create session with the relay by sending `server` command with the SMP relay address and optional proxy basic AUTH (below). It should be possible to batch multiple session requests into one block, to reduce traffic. +1. Client requests proxy to create session with the relay by sending `PRXY` command with the SMP relay address and optional proxy basic AUTH (below). It should be possible to batch multiple session requests into one block, to reduce traffic. -2. Proxy connects to SMP relay, negotiating a shared secret in the handshake that will be used to encrypt all sender blocks inside TLS (proxy-relay encryption). SMP relay also returns in handshake its temporary DH key to agree e2e encryption with the client (sender-relay encryption, to hide metadata sent to the destination relay from proxy). +2. Proxy connects to SMP relay, negotiating a shared secret via a handshake headers - it will be used to encrypt all sender blocks inside TLS (proxy-relay encryption). DH key returned by SMP relay in handshake will also be used to encrypt client commands, combining it with random per-command keys (sender-relay encryption, to hide metadata sent to the destination relay from proxy). -3. Proxy replies with `server_id` command including relay session ID to identify it in further requests, relay DH key for e2e encryption with the client - this key is signed with the TLS online private key associated with the certificate (its fingerprint is included in the relay address), and the TLS session ID between proxy and relay (this session ID must be used in transmissions, to mitigate replay attacks as before). +3. Proxy replies to sender with `PKEY` message using "entityId" transmission field to indicate session ID for using in further requests, relay DH key for _s2r_ encryption with the client - this key is signed with the TLS online private key associated with the certificate (its fingerprint is included in the relay address), and the TLS session ID between proxy and relay (this session ID must be used in transmissions, to mitigate replay attacks as before). A possible attack here is that proxy can use this TLS session to replay commands received from the client. Possibly, it could be mitigated with a bloom filter per proxy/SMP relay connection that would reject the repeated DH keys (that need to be used for replay), and also with DH key expiration (this mitigation should allow some acceptable rate of false positives from the bloom filter). @@ -113,11 +113,11 @@ It is important that the same public key from destination relay is returned to a *Unrelated cosideration for SMP protocol privacy improvement*: instead of signing commands to the destination relay, the sender could have a ratchet per queue agreed with the destination relay that would simply use authenticated encryption with per-message symmetric key to encrypt the message on the way to relay, and this encryption would be used as a proof of sender. -4. Now the client sends `forward` to proxy, which it then forwards to SMP relay, applying additional encryption layer. +4. Now the client sends `PFWD` to proxy, which it then forwards to SMP relay as `RFWD`, applying _p2r_ encryption layer. -5. SMP relay sends `response` to proxy applying additional encryption layer, which it then forwards to the client removing the additional encryption layer. +5. SMP relay sends `RRES` to proxy applying _p2r_ encryption layer, which it then forwards to the client as `PRES`, removing the _p2r_ encryption layer. -Effectively it works as a simplified two-hop onion routing with the first relay (proxy) chosen by the sending client and the second relay chosen by the recipient, not only protecting senders' IP addresses from the recipients' relays, but also preventing recipients relays from correlating senders' traffic to different queues, as TLS session is owned by the proxy now and it mixes the traffic from multiple senders. To correlate traffic to users, proxy and relay would have to combine their information. SMP relays are still able to correlate traffic to receiving users via transport session. +Effectively it works as a simplified two-hop onion routing with the first relay (proxy) chosen by the sending client and the second relay chosen by the recipient, not only protecting senders' IP addresses from the recipients' relays, but also preventing recipients' relays from correlating senders' traffic to different queues, as TLS session is owned by the proxy now and it mixes the traffic from multiple senders. To correlate traffic to users, proxy and relay would have to combine their information. SMP relays are still able to correlate traffic to receiving users via transport session. Sequence diagram for sending the message via SMP proxy: @@ -126,33 +126,33 @@ Sequence diagram for sending the message via SMP proxy: | sending | | SMP | | SMP | | receiving | | client | | proxy | | relay | | client | ------------- ------------- ------------- ------------- - | `server` | | | - | -------------------------> | create TLS session, get keys | | + | `PRXY` | | | + | -------------------------> | | | | | ------------------------------> | | - | `server_id` | (if doesn't exist) | | + | | SMP handshake | | + | | <------------------------------ | | + | `PKEY` | | | | <------------------------- | | | | | | | - | TLS(F:s2r(SEND(e2e(msg)))) | | | - | -------------------------> | TLS(F:p2r(s2r(SEND(e2e(msg))))) | | + | `PFWD` (s2r) | | | + | -------------------------> | | | + | | `RFWD` (p2r) | | | | ------------------------------> | | - | | | | - | | TLS(R:p2r(s2r(OK/ERR))) | | - | TLS(R:s2r(OK/ERR)) | <------------------------------ | | - | <------------------------- | | TLS(MSG(r2c(e2e(msg)))) | - | | | -----------------------> | - | | | | - | | | TLS(ACK) | + | | `RRES` (p2r) | | + | | <------------------------------ | | + | `PRES` (s2r) | | `MSG` | + | <------------------------- | | -----------------------> | + | | | `ACK` | | | | <----------------------- | | | | | | | | | - ``` -Below diagram shows the encrypttion layers for `forward` and `response` commands: +Below diagram shows the encrypttion layers for `PFWD`/`RFWD` commands and `RRES`/`PRES` responses: -- s2r (added) - encryption between client and SMP relay, with relay key returned in server_id command, with MITM by proxy mitigated by verifying the certificate fingerprint included in the relay address. +- s2r (added) - encryption between client and SMP relay, with relay key returned in relay handshake, with MITM by proxy mitigated by verifying the certificate fingerprint included in the relay address. - e2e (exists now) - end-to-end encryption per SMP queue, with double ratchet e2e encryption inside it. -- p2r (added) - additional encryption between proxy and SMP relay with key agreed in the handshake, to mitigate traffic correlation inside TLS. This key could also be signed by the same certificate, if we don't want to rely on TLS security. +- p2r (added) - additional encryption between proxy and SMP relay with the shared secret agreed in the handshake, to mitigate traffic correlation inside TLS. - r2c (exists now) additional encryption between SMP relay and client to prevent traffic correlation inside TLS. ``` @@ -167,27 +167,32 @@ Below diagram shows the encrypttion layers for `forward` and `response` commands ----------------- ----------------- -- TLS -- ----------------- ----------------- ``` -When proxy connects to SMP relay it would indicate in the handshake that it will use proxy protocol and the SMP relay would expect the same `forward` commands and reply with `response`s. +Question: should proxy declare its role in handshake? When proxy connects to SMP relay it would indicate in the handshake that it will act as a proxy and the SMP relay would expect the same `forward` commands and reply with `response`s. -Below syntax aims to fit in 16kb block using spare capacity in SMP protocol. +Common SMP transmission format (v4), for reference: ```abnf -proxy_block = padded(proxy_transmission, 16384) -proxy_transmission = corr_id relay_session_id proxy_command -corr_id = length *8 OCTET -proxy_command = server / server_id / forward / response / error -server = "S" address [relay_basic_auth] ; creates transport session between proxy and relay -server_id = "I" relay_session_id tls_session_id signed_relay_key ; - ; session_id is the TLS session ID between proxy and relay, it has to be included inside encrypted block to prevent replay attacks -forward = %s"F" random_dh_pub_key encrypted_block ; it's important that a new key is used for each command, to prevent any correlation by proxy or by destination relay -response = %s"R" encrypted_block; response received from the destination SMP relay -relay_session_id = length *8 OCTET -error = %s"E" error +paddedTransmission = +transmission = signature signed +signature = 0 ; empty signatures here +signed = sessionIdentifier corrId entityId (smpCommand / brokerMsg) ``` -The overhead is: 1+8 (corrId) + 1+8 (relay_session_id) + 1 (command) + 1+32 (random_dh_pub_key) + 2 (original length) + 16 (auth tag for e2e encryption) + 16 (auth tag for proxy to relay encryption) = 86 bytes. The reserve for sent messages in SMP is ~84 bytes, so it should about fit with some reduced bytes somewhere. +- `corrId` is fully random each time and used as a nonce for encrypted blocks. +- `entityId` carries tlsUniq from the current proxy-to-relay connection. +- `smpCommand` gets extended with `s2p_command / p2r_command`. +- `brokerMsg` gets extended with `r_key / r_response`. -Another possible design is to allow mixing sent messages and normal SMP commands in the same transport connection, but it can make fitting in the block a bit harder, additional overhead would be: 1 (transmission count) + 2 (transmission size) + 1 (empty signature) = 4 bytes. +```abnf +s2p_command = proxy / forward +p2r_command = p_handshake ; forward is +proxy = %s"PRXY" SP relayUri SP basicAuth +relayUri = length %s"smp://" serverIdentity "@" srvHost [":" port] +forward = %s"PFWD" SP dhPublic SP encryptedBlock +r_key = %s"PKEY" SP dhPublic +r_response = %s"RRES" SP encryptedBlock +dhPublic = length x509encoded +``` The above assumes that the client can only send one message to an SMP relay and then has to wait for response before sending the next message. Missing the response would cause re-delivery (further improvement is possible when proxy detects these redelieveries and not send them to relays but simply reply with the same response). diff --git a/simplexmq.cabal b/simplexmq.cabal index b85281080..95a5578ca 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -651,6 +651,7 @@ test-suite simplexmq-test ServerTests SMPAgentClient SMPClient + SMPProxyTests Util XFTPAgent XFTPCLI diff --git a/src/Simplex/Messaging/Crypto.hs b/src/Simplex/Messaging/Crypto.hs index 28183a1fc..bffd7559f 100644 --- a/src/Simplex/Messaging/Crypto.hs +++ b/src/Simplex/Messaging/Crypto.hs @@ -756,6 +756,8 @@ data Signature (a :: Algorithm) where SignatureEd25519 :: Ed25519.Signature -> Signature Ed25519 SignatureEd448 :: Ed448.Signature -> Signature Ed448 +deriving instance Eq (Signature a) + deriving instance Show (Signature a) data ASignature diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 2c593fc6f..77b14e23c 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -188,7 +188,9 @@ import Data.String import Data.Time.Clock.System (SystemTime (..)) import Data.Type.Equality import Data.Word (Word16) +import qualified Data.X509 as X import GHC.TypeLits (ErrorMessage (..), TypeError, type (+)) +import qualified GHC.TypeLits as TE import Network.Socket (ServiceName) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding @@ -359,6 +361,17 @@ data Command (p :: Party) where PING :: Command Sender -- SMP notification subscriber commands NSUB :: Command Notifier + PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- request a relay server connection by URI + -- Transmission to proxy: + -- - entity ID: ID of the session with relay returned in PKEY (response to PRXY) + -- - corrId: also used as a nonce to encrypt transmission to relay, corrId + 1 - from relay + -- - key (1st param in the command) is used to agree DH secret for this particular transmission and its response + -- Encrypted transmission should include session ID (tlsunique) from proxy-relay connection. + PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command Sender -- use CorrId as CbNonce, client to proxy + -- Transmission forwarded to relay: + -- - entity ID: empty + -- - corrId: unique correlation ID between proxy and relay, also used as a nonce to encrypt forwarded transmission + RFWD :: EncFwdTransmission -> Command Sender -- use CorrId as CbNonce, proxy to relay deriving instance Show (Command p) @@ -384,6 +397,18 @@ instance Encoding SubscriptionMode where 'C' -> pure SMOnlyCreate _ -> fail "bad SubscriptionMode" +newtype EncTransmission = EncTransmission ByteString + deriving (Show) + +data FwdTransmission = FwdTransmission + { fwdCorrId :: ByteString, + fwdKey :: C.PublicKeyX25519, + fwdTransmission :: ByteString + } + +newtype EncFwdTransmission = EncFwdTransmission ByteString + deriving (Show) + data BrokerMsg where -- SMP broker messages (responses, client messages, notifications) IDS :: QueueIdsKeys -> BrokerMsg @@ -393,6 +418,10 @@ data BrokerMsg where MSG :: RcvMessage -> BrokerMsg NID :: NotifierId -> RcvNtfPublicDhKey -> BrokerMsg NMSG :: C.CbNonce -> EncNMsgMeta -> BrokerMsg + -- Should include certificate chain + PKEY :: X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg -- TLS-signed server key for proxy shared secret and initial sender key + RRES :: EncFwdResponse -> BrokerMsg -- relay to proxy + PRES :: EncResponse -> BrokerMsg -- proxy to client END :: BrokerMsg OK :: BrokerMsg ERR :: ErrorType -> BrokerMsg @@ -405,6 +434,17 @@ data RcvMessage = RcvMessage } deriving (Eq, Show) +newtype EncFwdResponse = EncFwdResponse ByteString + deriving (Eq, Show) + +data FwdResponse = FwdResponse + { fwdCorrId :: ByteString, + fwdResponse :: ByteString + } + +newtype EncResponse = EncResponse ByteString + deriving (Eq, Show) + -- | received message without server/recipient encryption data Message = Message @@ -567,6 +607,9 @@ data CommandTag (p :: Party) where DEL_ :: CommandTag Recipient SEND_ :: CommandTag Sender PING_ :: CommandTag Sender + PRXY_ :: CommandTag Sender + PFWD_ :: CommandTag Sender + RFWD_ :: CommandTag Sender NSUB_ :: CommandTag Notifier data CmdTag = forall p. PartyI p => CT (SParty p) (CommandTag p) @@ -580,6 +623,9 @@ data BrokerMsgTag | MSG_ | NID_ | NMSG_ + | PKEY_ + | RRES_ + | PRES_ | END_ | OK_ | ERR_ @@ -607,6 +653,9 @@ instance PartyI p => Encoding (CommandTag p) where DEL_ -> "DEL" SEND_ -> "SEND" PING_ -> "PING" + PRXY_ -> "PRXY" + PFWD_ -> "PFWD" + RFWD_ -> "RFWD" NSUB_ -> "NSUB" smpP = messageTagP @@ -623,6 +672,9 @@ instance ProtocolMsgTag CmdTag where "DEL" -> Just $ CT SRecipient DEL_ "SEND" -> Just $ CT SSender SEND_ "PING" -> Just $ CT SSender PING_ + "PRXY" -> Just $ CT SSender PRXY_ + "PFWD" -> Just $ CT SSender PFWD_ + "RFWD" -> Just $ CT SSender RFWD_ "NSUB" -> Just $ CT SNotifier NSUB_ _ -> Nothing @@ -639,6 +691,9 @@ instance Encoding BrokerMsgTag where MSG_ -> "MSG" NID_ -> "NID" NMSG_ -> "NMSG" + PKEY_ -> "PKEY" + RRES_ -> "RRES" + PRES_ -> "PRES" END_ -> "END" OK_ -> "OK" ERR_ -> "ERR" @@ -651,6 +706,9 @@ instance ProtocolMsgTag BrokerMsgTag where "MSG" -> Just MSG_ "NID" -> Just NID_ "NMSG" -> Just NMSG_ + "PKEY" -> Just PKEY_ + "RRES" -> Just RRES_ + "PRES" -> Just PRES_ "END" -> Just END_ "OK" -> Just OK_ "ERR" -> Just ERR_ @@ -829,7 +887,7 @@ type family UserProtocol (p :: ProtocolType) :: Constraint where UserProtocol PSMP = () UserProtocol PXFTP = () UserProtocol a = - (Int ~ Bool, TypeError (Text "Servers for protocol " :<>: ShowType a :<>: Text " cannot be configured by the users")) + (Int ~ Bool, TypeError (TE.Text "Servers for protocol " :<>: ShowType a :<>: TE.Text " cannot be configured by the users")) userProtocol :: SProtocolType p -> Maybe (Dict (UserProtocol p)) userProtocol = \case @@ -1046,6 +1104,8 @@ data ErrorType NO_MSG | -- | sent message is too large (> maxMessageLength = 16088 bytes) LARGE_MSG + | -- | relay public key is expired + EXPIRED | -- | internal server error INTERNAL | -- | used internally, never returned by the server (to be removed) @@ -1135,6 +1195,9 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where SEND flags msg -> e (SEND_, ' ', flags, ' ', Tail msg) PING -> e PING_ NSUB -> e NSUB_ + PRXY host auth_ -> e (PRXY_, ' ', strEncode host, ' ', auth_) + PFWD {} -> error "TODO: e (PFWD_,,)" + RFWD {} -> error "TODO: e (RFWD_,,)" where e :: Encoding a => a -> ByteString e = smpEncode @@ -1158,6 +1221,9 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PING | isNothing auth && B.null queueId -> Right cmd | otherwise -> Left $ CMD HAS_AUTH + PRXY {} + | isNothing auth && B.null queueId -> Right cmd + | otherwise -> Left $ CMD HAS_AUTH -- other client commands must have both signature and queue ID _ | isNothing auth || B.null queueId -> Left $ CMD NO_AUTH @@ -1189,6 +1255,10 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where Cmd SSender <$> case tag of SEND_ -> SEND <$> _smpP <*> (unTail <$> _smpP) PING_ -> pure PING + PFWD_ -> error "TODO: PFWD_" + RFWD_ -> error "TODO: RFWD_" + PRXY_ -> PRXY <$> (_smpP >>= either fail pure . strDecode) <*> _smpP + CT SNotifier NSUB_ -> pure $ Cmd SNotifier NSUB fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg @@ -1204,6 +1274,9 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where e (MSG_, ' ', msgId, Tail body) NID nId srvNtfDh -> e (NID_, ' ', nId, srvNtfDh) NMSG nmsgNonce encNMsgMeta -> e (NMSG_, ' ', nmsgNonce, encNMsgMeta) + PKEY cert key -> e (PKEY_, ' ', C.encodeCertChain cert, C.SignedObject key) + RRES (EncFwdResponse encBlock) -> e (RRES_, ' ', Tail encBlock) + PRES (EncResponse encBlock) -> e (PRES_, ' ', Tail encBlock) END -> e END_ OK -> e OK_ ERR err -> e (ERR_, ' ', err) @@ -1221,6 +1294,9 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where IDS_ -> IDS <$> (QIK <$> _smpP <*> smpP <*> smpP) NID_ -> NID <$> _smpP <*> smpP NMSG_ -> NMSG <$> _smpP <*> smpP + PKEY_ -> PKEY <$> (A.space *> C.certChainP) <*> (C.getSignedExact <$> smpP) + RRES_ -> RRES <$> (EncFwdResponse . unTail <$> _smpP) + PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP) END_ -> pure END OK_ -> pure OK ERR_ -> ERR <$> _smpP @@ -1270,6 +1346,7 @@ instance Encoding ErrorType where CMD err -> "CMD " <> smpEncode err AUTH -> "AUTH" QUOTA -> "QUOTA" + EXPIRED -> "EXPIRED" NO_MSG -> "NO_MSG" LARGE_MSG -> "LARGE_MSG" INTERNAL -> "INTERNAL" @@ -1282,6 +1359,7 @@ instance Encoding ErrorType where "CMD" -> CMD <$> _smpP "AUTH" -> pure AUTH "QUOTA" -> pure QUOTA + "EXPIRED" -> pure EXPIRED "NO_MSG" -> pure NO_MSG "LARGE_MSG" -> pure LARGE_MSG "INTERNAL" -> pure INTERNAL diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 4535c7bd5..456de5be2 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -524,6 +524,9 @@ verifyTransmission auth_ tAuth authorized queueId cmd = Cmd SSender PING -> pure $ VRVerified Nothing -- NSUB will not be accepted without authorization Cmd SNotifier NSUB -> verifyQueue (\q -> maybe dummyVerify (Just q `verifiedWith`) (notifierKey <$> notifier q)) <$> get SNotifier + Cmd SSender PRXY {} -> pure $ VRVerified Nothing + Cmd SSender PFWD {} -> pure $ VRVerified Nothing + Cmd SSender RFWD {} -> pure $ VRVerified Nothing where verify = verifyCmdAuthorization auth_ tAuth authorized dummyVerify = verify (dummyAuthKey tAuth) `seq` VRFailed @@ -597,6 +600,17 @@ client clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Serv case command of SEND flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody PING -> pure (corrId, "", PONG) + PRXY relay auth -> + ifM + allowProxy + (setupProxy relay) + (pure (corrId, queueId, ERR AUTH)) + where + allowProxy = do + ServerConfig {allowSMPProxy, newQueueBasicAuth} <- asks config + pure $ allowSMPProxy && maybe True ((== auth) . Just) newQueueBasicAuth + PFWD _dhPub _encBlock -> error "TODO: processCommand.PFWD" + RFWD _encBlock -> error "TODO: processCommand.RFWD" Cmd SNotifier NSUB -> subscribeNotifications Cmd SRecipient command -> case command of @@ -922,6 +936,15 @@ client clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Serv Right q -> updateDeletedStats q $> ok Left e -> pure $ err e + setupProxy :: SMPServer -> M (Transmission BrokerMsg) + setupProxy todo'relay = undefined + -- do + -- let relaySessionId = "TODO: relaySessionId" + -- (dummyRelayDhPublic, _) <- atomically . C.generateKeyPair =<< asks random + -- (_, dummySignKey) <- atomically . C.generateKeyPair =<< asks random + -- let dummyRelayKeySignature = C.sign' dummySignKey $ smpEncode dummyRelayDhPublic + -- pure (corrId, relaySessionId, PKEY dummyRelayDhPublic dummyRelayKeySignature) + ok :: Transmission BrokerMsg ok = (corrId, queueId, OK) diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index baadfc79b..74d7d96e3 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -15,12 +15,14 @@ import qualified Data.IntMap.Strict as IM import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Text (Text) import Data.Time.Clock (getCurrentTime) import Data.Time.Clock.System (SystemTime) import Data.X509.Validation (Fingerprint (..)) import Network.Socket (ServiceName) import qualified Network.TLS as T import Numeric.Natural +import Simplex.Messaging.Agent.Env.SQLite (Worker) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Crypto (KeyHash (..)) import qualified Simplex.Messaging.Crypto as C @@ -33,7 +35,7 @@ import Simplex.Messaging.Server.Stats import Simplex.Messaging.Server.StoreLog import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport, VersionSMP, VersionRangeSMP) +import Simplex.Messaging.Transport (ATransport, SessionId, VersionSMP, VersionRangeSMP) import Simplex.Messaging.Transport.Server (SocketState, TransportServerConfig, loadFingerprint, loadTLSServerParams, newSocketState) import System.IO (IOMode (..)) import System.Mem.Weak (Weak) @@ -79,7 +81,8 @@ data ServerConfig = ServerConfig -- | TCP transport config transportConfig :: TransportServerConfig, -- | run listener on control port - controlPort :: Maybe ServiceName + controlPort :: Maybe ServiceName, + allowSMPProxy :: Bool -- auth is the same with `newQueueBasicAuth` } defMsgExpirationDays :: Int64 @@ -110,8 +113,9 @@ data Env = Env tlsServerParams :: T.ServerParams, serverStats :: ServerStats, sockets :: SocketState, - clientSeq :: TVar Int, - clients :: TVar (IntMap Client) + clientSeq :: TVar ClientId, + clients :: TVar (IntMap Client), + proxyServer :: SMPProxyServer -- senders served on this proxy } data Server = Server @@ -122,8 +126,22 @@ data Server = Server savingLock :: Lock } +data SMPProxyServer = SMPProxyServer + { relaySessions :: TMap SessionId SMPProxiedRelay, + relayServers :: TMap Text SessionId -- speed up client lookups by server URI + } + +data SMPProxiedRelay = SMPProxiedRelay + { worker :: Worker, + proxyKey :: C.DhSecretX25519, + fwdQ :: TBQueue (ClientId, CorrId, C.PublicKeyX25519, ByteString) -- FWD args from multiple clients using this server + -- can be used for QUOTA retries until the session is gone + } + +type ClientId = Int + data Client = Client - { clientId :: Int, + { clientId :: ClientId, subscriptions :: TMap RecipientId (TVar Sub), ntfSubscriptions :: TMap NotifierId (), rcvQ :: TBQueue (NonEmpty (Maybe QueueRec, Transmission Cmd)), @@ -135,7 +153,8 @@ data Client = Client connected :: TVar Bool, createdAt :: SystemTime, rcvActiveAt :: TVar SystemTime, - sndActiveAt :: TVar SystemTime + sndActiveAt :: TVar SystemTime, + proxyClient_ :: TVar (Maybe C.DhSecretX25519) -- this client is actually an SMP proxy } data SubscriptionThread = NoSub | SubPending | SubThread (Weak ThreadId) | ProhibitSub @@ -154,7 +173,7 @@ newServer = do savingLock <- createLock return Server {subscribedQ, subscribers, ntfSubscribedQ, notifiers, savingLock} -newClient :: TVar Int -> Natural -> VersionSMP -> ByteString -> SystemTime -> STM Client +newClient :: TVar ClientId -> Natural -> VersionSMP -> ByteString -> SystemTime -> STM Client newClient nextClientId qSize thVersion sessionId createdAt = do clientId <- stateTVar nextClientId $ \next -> (next, next + 1) subscriptions <- TM.empty @@ -166,7 +185,8 @@ newClient nextClientId qSize thVersion sessionId createdAt = do connected <- newTVar True rcvActiveAt <- newTVar createdAt sndActiveAt <- newTVar createdAt - return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt} + proxyClient_ <- newTVar Nothing + return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt, proxyClient_} newSubscription :: SubscriptionThread -> STM Sub newSubscription subThread = do @@ -187,7 +207,8 @@ newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, sockets <- atomically newSocketState clientSeq <- newTVarIO 0 clients <- newTVarIO mempty - return Env {config, server, serverIdentity, queueStore, msgStore, random, storeLog, tlsServerParams, serverStats, sockets, clientSeq, clients} + proxyServer <- newSMPProxyServer + return Env {config, server, serverIdentity, queueStore, msgStore, random, storeLog, tlsServerParams, serverStats, sockets, clientSeq, clients, proxyServer} where restoreQueues :: QueueStore -> FilePath -> IO (StoreLog 'WriteMode) restoreQueues QueueStore {queues, senders, notifiers} f = do @@ -203,3 +224,9 @@ newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, addNotifier q = case notifier q of Nothing -> id Just NtfCreds {notifierId} -> M.insert notifierId (recipientId q) + +newSMPProxyServer :: MonadIO m => m SMPProxyServer +newSMPProxyServer = do + relayServers <- atomically TM.empty + relaySessions <- atomically TM.empty + pure SMPProxyServer {relayServers, relaySessions} diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index a7844cc95..d14bdac1f 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -213,7 +213,8 @@ smpServerCLI cfgPath logPath = defaultTransportServerConfig { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini }, - controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini + controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini, + allowSMPProxy = True -- TODO: "get from INI" } data CliCommand diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 6898af15d..3d4916b92 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -40,6 +40,7 @@ module Simplex.Messaging.Transport basicAuthSMPVersion, subModeSMPVersion, authCmdsSMPVersion, + sendingProxySMPVersion, simplexMQVersion, smpBlockSize, TransportConfig (..), @@ -148,6 +149,9 @@ subModeSMPVersion = VersionSMP 6 authCmdsSMPVersion :: VersionSMP authCmdsSMPVersion = VersionSMP 7 +sendingProxySMPVersion :: VersionSMP +sendingProxySMPVersion = VersionSMP 8 + currentClientSMPRelayVersion :: VersionSMP currentClientSMPRelayVersion = VersionSMP 6 diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 3e5e9d2ce..b63668f7a 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -73,7 +73,11 @@ testSMPClient = testSMPClientVR supportedClientSMPRelayVRange testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c -> IO a) -> IO a testSMPClientVR vr client = do Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost - runTransportClient defaultTransportClientConfig Nothing useHost testPort (Just testKeyHash) $ \h -> do + testSMPClient_ useHost testPort vr client + +testSMPClient_ :: Transport c => TransportHost -> ServiceName -> VersionRangeSMP -> (THandleSMP c -> IO a) -> IO a +testSMPClient_ host port vr client = do + runTransportClient defaultTransportClientConfig Nothing host port (Just testKeyHash) $ \h -> do g <- C.newRandom ks <- atomically $ C.generateKeyPair g runExceptT (smpClientHandshake h ks testKeyHash vr) >>= \case @@ -107,12 +111,16 @@ cfg = certificateFile = "tests/fixtures/server.crt", smpServerVRange = supportedServerSMPRelayVRange, transportConfig = defaultTransportServerConfig, - controlPort = Nothing + controlPort = Nothing, + allowSMPProxy = False } cfgV7 :: ServerConfig cfgV7 = cfg {smpServerVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} +proxyCfg :: ServerConfig +proxyCfg = cfg { allowSMPProxy = True } + withSmpServerStoreMsgLogOn :: HasCallStack => ATransport -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a withSmpServerStoreMsgLogOn t = withSmpServerConfigOn t cfg {storeLogFile = Just testStoreLogFile, storeMsgsFile = Just testStoreMsgsFile, serverStatsBackupFile = Just testServerStatsBackupFile} diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs new file mode 100644 index 000000000..0a429fc57 --- /dev/null +++ b/tests/SMPProxyTests.hs @@ -0,0 +1,67 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} + +module SMPProxyTests where + +import SMPAgentClient (testSMPServer, testSMPServer2) +import SMPClient +import ServerTests (sendRecv) +import Simplex.Messaging.Protocol +import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) +import Simplex.Messaging.Transport +import Simplex.Messaging.Version (mkVersionRange) +import Test.Hspec +import Debug.Trace + +smpProxyTests :: Spec +smpProxyTests = do + describe "server configuration" $ do + it "refuses proxy handshake unless enabled" testNoProxy + it "checks basic auth in proxy requests" testProxyAuth + xdescribe "proxy requests" $ do + xdescribe "bad relay URIs" $ do + it "host not resolved" todo + it "when SMP port blackholed" todo + it "no SMP service at host/port" todo + it "bad SMP fingerprint" todo + it "connects to relay" testProxyConnect + xit "connects to itself as a relay" todo + xit "batching proxy requests" todo + xdescribe "forwarding requests" $ do + it "sender-proxy-relay-recipient works" todo + it "similar timing for proxied and direct sends" todo + +proxyVRange :: VersionRangeSMP +proxyVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion + +testNoProxy :: IO () +testNoProxy = do + withSmpServerConfigOn (transport @TLS) cfg testPort2 $ \_ -> do + testSMPClient_ "127.0.0.1" testPort2 proxyVRange $ \(th :: THandleSMP TLS) -> do + (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer Nothing) + reply `shouldBe` Right (ERR AUTH) + +testProxyAuth :: IO () +testProxyAuth = do + withSmpServerConfigOn (transport @TLS) proxyCfgAuth testPort $ \_ -> do + testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS) -> do + (_, s, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 $ Just "wrong") + traceShowM s + reply `shouldBe` Right (ERR AUTH) + where + proxyCfgAuth = proxyCfg {newQueueBasicAuth = Just "correct"} + +testProxyConnect :: IO () +testProxyConnect = do + withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> do + testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS) -> do + (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 Nothing) + case reply of + Right PKEY {} -> pure () + _ -> fail $ "bad reply: " <> show reply + +todo :: IO () +todo = do + fail "TODO" diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index 09cf975c1..3b5f2f5dc 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -930,8 +930,6 @@ instance Eq C.ASignature where Just Refl -> s == s' _ -> False -deriving instance Eq (C.Signature a) - syntaxTests :: ATransport -> Spec syntaxTests (ATransport t) = do it "unknown command" $ ("", "abcd", "1234", ('H', 'E', 'L', 'L', 'O')) >#> ("", "abcd", "1234", ERR $ CMD UNKNOWN) diff --git a/tests/Test.hs b/tests/Test.hs index aebceb22d..f9fb2a2c0 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -21,6 +21,7 @@ import GHC.IO.Exception (IOException (..)) import qualified GHC.IO.Exception as IOException import NtfServerTests (ntfServerTests) import RemoteControl (remoteControlTests) +import SMPProxyTests (smpProxyTests) import ServerTests import Simplex.Messaging.Transport (TLS, Transport (..)) import Simplex.Messaging.Transport.WebSockets (WS) @@ -59,6 +60,7 @@ main = do describe "SMP server via WebSockets" $ serverTests (transport @WS) describe "Notifications server" $ ntfServerTests (transport @TLS) describe "SMP client agent" $ agentTests (transport @TLS) + describe "SMP proxy" smpProxyTests describe "XFTP" $ do describe "XFTP server" xftpServerTests describe "XFTP file description" fileDescriptionTests From b741b6570047cf7ad8d2652c417df87a2646e0c2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 12 Apr 2024 22:22:24 +0100 Subject: [PATCH 003/107] use online status in network information (#1094) * use online status in network information * test * refactor * flip condition * fix --- src/Simplex/Messaging/Agent.hs | 15 ++++---- src/Simplex/Messaging/Agent/Client.hs | 3 +- tests/AgentTests/FunctionalAPITests.hs | 49 +++++++++++++++++++------- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 3f7ed0d90..e0cfb2aca 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -419,15 +419,18 @@ getNetworkConfig = fmap snd . readTVarIO . useNetworkConfig {-# INLINE getNetworkConfig #-} setUserNetworkInfo :: AgentClient -> UserNetworkInfo -> IO () -setUserNetworkInfo c@AgentClient {userNetworkState} UserNetworkInfo {networkType = nt'} = withAgentEnv' c $ do +setUserNetworkInfo c@AgentClient {userNetworkState} UserNetworkInfo {networkType = nt', online} = withAgentEnv' c $ do d <- asks $ initialInterval . userNetworkInterval . config ts <- liftIO getCurrentTime atomically $ do - ns@UserNetworkState {networkType = nt} <- readTVar userNetworkState - when (nt' /= nt) $ - writeTVar userNetworkState $! case nt' of - UNNone -> ns {networkType = nt', offline = Just UNSOffline {offlineDelay = d, offlineFrom = ts}} - _ -> ns {networkType = nt', offline = Nothing} + ns@UserNetworkState {networkType = nt, offline} <- readTVar userNetworkState + when (nt' /= nt || online /= isNothing offline) $ + writeTVar userNetworkState $! + let offline' + | nt' /= UNNone && online = Nothing + | isJust offline = offline + | otherwise = Just UNSOffline {offlineDelay = d, offlineFrom = ts} + in ns {networkType = nt', offline = offline'} reconnectAllServers :: AgentClient -> IO () reconnectAllServers c = do diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 3091fba87..7041f10c6 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -405,7 +405,8 @@ data AgentStatsKey = AgentStatsKey deriving (Eq, Ord, Show) data UserNetworkInfo = UserNetworkInfo - { networkType :: UserNetworkType + { networkType :: UserNetworkType, + online :: Bool } deriving (Show) diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 7d6884b27..301be97b4 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -428,6 +428,7 @@ functionalAPITests t = do it "send delivery receipts concurrently with messages" $ testDeliveryReceiptsConcurrent t describe "user network info" $ do it "should wait for user network" testWaitForUserNetwork + it "should not reset offline interval while offline" testDoNotResetOfflineInterval testBasicAuth :: ATransport -> Bool -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> IO Int testBasicAuth t allowNewQueues srv@(srvAuth, srvVersion) clnt1 clnt2 = do @@ -2661,32 +2662,56 @@ testServerMultipleIdentities = } testE2ERatchetParams12 -testWaitForUserNetwork :: HasCallStack => IO () +testWaitForUserNetwork :: IO () testWaitForUserNetwork = do a <- getSMPAgentClient' 1 aCfg initAgentServers testDB noNetworkDelay a - setUserNetworkInfo a $ UserNetworkInfo UNNone + setUserNetworkInfo a $ UserNetworkInfo UNNone False networkDelay a 100000 networkDelay a 150000 networkDelay a 200000 networkDelay a 200000 - setUserNetworkInfo a $ UserNetworkInfo UNCellular + setUserNetworkInfo a $ UserNetworkInfo UNCellular True noNetworkDelay a - setUserNetworkInfo a $ UserNetworkInfo UNNone + setUserNetworkInfo a $ UserNetworkInfo UNCellular False networkDelay a 100000 concurrently_ - (threadDelay 50000 >> setUserNetworkInfo a (UserNetworkInfo UNCellular)) + (threadDelay 50000 >> setUserNetworkInfo a (UserNetworkInfo UNCellular True)) (networkDelay a 50000) noNetworkDelay a where aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 200000}} - noNetworkDelay a = (10000 >) <$> waitNetwork a `shouldReturn` True - networkDelay a d' = (\d -> d' < d && d < d' + 15000) <$> waitNetwork a `shouldReturn` True - waitNetwork a = do - t <- getCurrentTime - waitForUserNetwork a `runReaderT` agentEnv a - t' <- getCurrentTime - pure $ diffToMicroseconds $ diffUTCTime t' t + +testDoNotResetOfflineInterval :: IO () +testDoNotResetOfflineInterval = do + a <- getSMPAgentClient' 1 aCfg initAgentServers testDB + noNetworkDelay a + setUserNetworkInfo a $ UserNetworkInfo UNWifi False + networkDelay a 100000 + networkDelay a 150000 + setUserNetworkInfo a $ UserNetworkInfo UNCellular False + networkDelay a 200000 + setUserNetworkInfo a $ UserNetworkInfo UNNone False + networkDelay a 200000 + setUserNetworkInfo a $ UserNetworkInfo UNCellular True + noNetworkDelay a + setUserNetworkInfo a $ UserNetworkInfo UNCellular False + networkDelay a 100000 + where + aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 200000}} + +noNetworkDelay :: AgentClient -> IO () +noNetworkDelay a = (10000 >) <$> waitNetwork a `shouldReturn` True + +networkDelay :: AgentClient -> Int64 -> IO () +networkDelay a d' = (\d -> d' < d && d < d' + 15000) <$> waitNetwork a `shouldReturn` True + +waitNetwork :: AgentClient -> IO Int64 +waitNetwork a = do + t <- getCurrentTime + waitForUserNetwork a `runReaderT` agentEnv a + t' <- getCurrentTime + pure $ diffToMicroseconds $ diffUTCTime t' t exchangeGreetings :: HasCallStack => AgentClient -> ConnId -> AgentClient -> ConnId -> ExceptT AgentErrorType IO () exchangeGreetings = exchangeGreetings_ PQEncOn From 5e783396e05ced83b22914c172d2f2e8e14e5be2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 12 Apr 2024 23:14:20 +0100 Subject: [PATCH 004/107] 5.6.2.2: v5.6.2 --- CHANGELOG.md | 19 +++++++++++++++++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed15e7083..e06ac1caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 5.6.2 + +Version 5.6.2.2. + +SMP agent: +- Lower memory consumption (~20-25%). +- More stable XFTP file uploads and downloads. +- API to receive network connectivity changes from the apps. +- to reduce battery consumption: connection attempts interval growing to every 2 hours when app reports as offline. +- to reduce retries and traffic: 50% increased timeouts when on mobile network. + +XFTP server: +- expire files on start. +- version negotiation based on TLS ALPN and handshake. + +NTF server: +- reduced downtime by ~100x faster start time. +- exclude test tokens from statistics. + # 5.6.1 Version 5.6.1.0. diff --git a/package.yaml b/package.yaml index 60d58be10..c68416384 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.6.2.1 +version: 5.6.2.2 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index b85281080..7c6433727 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.6.2.1 +version: 5.6.2.2 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From ebb75ced121353aa4ec2b64cf1582af86015f0e2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 13 Apr 2024 18:33:12 +0100 Subject: [PATCH 005/107] extract SessionVar from AgentClient to reuse (#1099) --- simplexmq.cabal | 1 + src/Simplex/Messaging/Agent/Client.hs | 68 ++++++++---------------- src/Simplex/Messaging/Client/Agent.hs | 76 +++++++++++++++------------ src/Simplex/Messaging/Session.hs | 38 ++++++++++++++ 4 files changed, 104 insertions(+), 79 deletions(-) create mode 100644 src/Simplex/Messaging/Session.hs diff --git a/simplexmq.cabal b/simplexmq.cabal index 7c6433727..6d04f9775 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -147,6 +147,7 @@ library Simplex.Messaging.Server.Stats Simplex.Messaging.Server.StoreLog Simplex.Messaging.ServiceScheme + Simplex.Messaging.Session Simplex.Messaging.TMap Simplex.Messaging.Transport Simplex.Messaging.Transport.Buffer diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 7041f10c6..4fbcae425 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -152,7 +152,6 @@ import Data.Bifunctor (bimap, first, second) import Data.ByteString.Base64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Composition ((.:.)) import Data.Either (lefts, partitionEithers) import Data.Functor (($>)) import Data.Int (Int64) @@ -227,6 +226,7 @@ import Simplex.Messaging.Protocol sameSrvAddr', ) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (SMPVersion) @@ -241,11 +241,6 @@ import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, removeFile) import qualified UnliftIO.Exception as E import UnliftIO.STM -data SessionVar a = SessionVar - { sessionVar :: TMVar a, - sessionVarId :: Int - } - type ClientVar msg = SessionVar (Either AgentErrorType (Client msg)) type SMPClientVar = ClientVar SMP.BrokerMsg @@ -550,9 +545,9 @@ instance ProtocolServerClient XFTPVersion XFTPErrorType FileResponse where clientSessionTs = X.xftpSessionTs getSMPServerClient :: AgentClient -> SMPTransportSession -> AM SMPClient -getSMPServerClient c@AgentClient {active, smpClients, msgQ} tSess@(userId, srv, _) = do +getSMPServerClient c@AgentClient {active, smpClients, msgQ, workerSeq} tSess@(userId, srv, _) = do unlessM (readTVarIO active) . throwError $ INACTIVE - atomically (getTSessVar c tSess smpClients) + atomically (getSessVar workerSeq tSess smpClients) >>= either newClient (waitForProtocolClient c tSess) where -- we resubscribe only on newClient error, but not on waitForProtocolClient error, @@ -581,7 +576,7 @@ getSMPServerClient c@AgentClient {active, smpClients, msgQ} tSess@(userId, srv, removeClientAndSubs :: IO ([RcvQueue], [ConnId]) removeClientAndSubs = atomically $ ifM currentActiveClient removeSubs $ pure ([], []) where - currentActiveClient = (&&) <$> removeTSessVar' v tSess smpClients <*> readTVar active + currentActiveClient = (&&) <$> removeSessVar' v tSess smpClients <*> readTVar active removeSubs = do (qs, cs) <- RQ.getDelSessQueues tSess $ activeSubs c RQ.batchAddQueues (pendingSubs c) qs @@ -600,14 +595,14 @@ getSMPServerClient c@AgentClient {active, smpClients, msgQ} tSess@(userId, srv, notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) resubscribeSMPSession :: AgentClient -> SMPTransportSession -> AM' () -resubscribeSMPSession c@AgentClient {smpSubWorkers} tSess = +resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = atomically getWorkerVar >>= mapM_ (either newSubWorker (\_ -> pure ())) where getWorkerVar = ifM (null <$> getPending) (pure Nothing) -- prevent race with cleanup and adding pending queues in another call - (Just <$> getTSessVar c tSess smpSubWorkers) + (Just <$> getSessVar workerSeq tSess smpSubWorkers) newSubWorker v = do a <- async $ void (E.tryAny runSubWorker) >> atomically (cleanup v) atomically $ putTMVar (sessionVar v) a @@ -626,7 +621,7 @@ resubscribeSMPSession c@AgentClient {smpSubWorkers} tSess = -- Here we wait until TMVar is not empty to prevent worker cleanup happening before worker is added to TMVar. -- Not waiting may result in terminated worker remaining in the map. whenM (isEmptyTMVar $ sessionVar v) retry - removeTSessVar v tSess smpSubWorkers + removeSessVar v tSess smpSubWorkers reconnectSMPClient :: TVar Int -> AgentClient -> SMPTransportSession -> NonEmpty RcvQueue -> AM () reconnectSMPClient tc c tSess@(_, srv, _) qs = do @@ -660,9 +655,9 @@ reconnectSMPClient tc c tSess@(_, srv, _) qs = do notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) getNtfServerClient :: AgentClient -> NtfTransportSession -> AM NtfClient -getNtfServerClient c@AgentClient {active, ntfClients} tSess@(userId, srv, _) = do +getNtfServerClient c@AgentClient {active, ntfClients, workerSeq} tSess@(userId, srv, _) = do unlessM (readTVarIO active) . throwError $ INACTIVE - atomically (getTSessVar c tSess ntfClients) + atomically (getSessVar workerSeq tSess ntfClients) >>= either (newProtocolClient c tSess ntfClients connectClient) (waitForProtocolClient c tSess) @@ -677,15 +672,15 @@ getNtfServerClient c@AgentClient {active, ntfClients} tSess@(userId, srv, _) = d clientDisconnected :: NtfClientVar -> NtfClient -> IO () clientDisconnected v client = do - atomically $ removeTSessVar v tSess ntfClients + atomically $ removeSessVar v tSess ntfClients incClientStat c userId client "DISCONNECT" "" atomically $ writeTBQueue (subQ c) ("", "", APC SAENone $ hostEvent DISCONNECT client) logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv getXFTPServerClient :: AgentClient -> XFTPTransportSession -> AM XFTPClient -getXFTPServerClient c@AgentClient {active, xftpClients} tSess@(userId, srv, _) = do +getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId, srv, _) = do unlessM (readTVarIO active) . throwError $ INACTIVE - atomically (getTSessVar c tSess xftpClients) + atomically (getSessVar workerSeq tSess xftpClients) >>= either (newProtocolClient c tSess xftpClients connectClient) (waitForProtocolClient c tSess) @@ -701,32 +696,11 @@ getXFTPServerClient c@AgentClient {active, xftpClients} tSess@(userId, srv, _) = clientDisconnected :: XFTPClientVar -> XFTPClient -> IO () clientDisconnected v client = do - atomically $ removeTSessVar v tSess xftpClients + atomically $ removeSessVar v tSess xftpClients incClientStat c userId client "DISCONNECT" "" atomically $ writeTBQueue (subQ c) ("", "", APC SAENone $ hostEvent DISCONNECT client) logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv -getTSessVar :: forall a s. AgentClient -> TransportSession s -> TMap (TransportSession s) (SessionVar a) -> STM (Either (SessionVar a) (SessionVar a)) -getTSessVar c tSess vs = maybe (Left <$> newSessionVar) (pure . Right) =<< TM.lookup tSess vs - where - newSessionVar :: STM (SessionVar a) - newSessionVar = do - sessionVar <- newEmptyTMVar - sessionVarId <- stateTVar (workerSeq c) $ \next -> (next, next + 1) - let v = SessionVar {sessionVar, sessionVarId} - TM.insert tSess v vs - pure v - -removeTSessVar :: SessionVar a -> TransportSession msg -> TMap (TransportSession msg) (SessionVar a) -> STM () -removeTSessVar = void .:. removeTSessVar' -{-# INLINE removeTSessVar #-} - -removeTSessVar' :: SessionVar a -> TransportSession msg -> TMap (TransportSession msg) (SessionVar a) -> STM Bool -removeTSessVar' v tSess vs = - TM.lookup tSess vs >>= \case - Just v' | sessionVarId v == sessionVarId v' -> TM.delete tSess vs $> True - _ -> pure False - waitForProtocolClient :: ProtocolTypeI (ProtoType msg) => AgentClient -> TransportSession msg -> ClientVar msg -> AM (Client msg) waitForProtocolClient c (_, srv, _) v = do NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c @@ -757,7 +731,7 @@ newProtocolClient c tSess@(userId, srv, entityId_) clients connectClient v = Left e -> do liftIO $ incServerStat c userId srv "CLIENT" $ strEncode e atomically $ do - removeTSessVar v tSess clients + removeSessVar v tSess clients putTMVar (sessionVar v) (Left e) throwError e -- signal error to caller @@ -781,10 +755,11 @@ getNetworkConfig c = do waitForUserNetwork :: AgentClient -> AM' () waitForUserNetwork AgentClient {userNetworkState} = - (offline <$> readTVarIO userNetworkState) >>= mapM_ waitWhileOffline + readTVarIO userNetworkState >>= mapM_ waitWhileOffline . offline where waitWhileOffline UNSOffline {offlineDelay = d} = - unlessM (liftIO $ waitOnline d False) $ do -- network delay reached, increase delay + unlessM (liftIO $ waitOnline d False) $ do + -- network delay reached, increase delay ts' <- liftIO getCurrentTime ni <- asks $ userNetworkInterval . config atomically $ do @@ -794,7 +769,7 @@ waitForUserNetwork AgentClient {userNetworkState} = -- and to reset `offlineDelay` if network went `on` and `off` again. writeTVar userNetworkState $! let d'' = nextRetryDelay (diffToMicroseconds $ diffUTCTime ts' ts) (min d d') ni - in ns {offline = Just UNSOffline {offlineDelay = d'', offlineFrom = ts}} + in ns {offline = Just UNSOffline {offlineDelay = d'', offlineFrom = ts}} waitOnline :: Int64 -> Bool -> IO Bool waitOnline t online' | t <= 0 = pure online' @@ -866,9 +841,10 @@ closeClient c clientSel tSess = closeClient_ :: ProtocolServerClient v err msg => AgentClient -> ClientVar msg -> IO () closeClient_ c v = do NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c - E.handle (\BlockedIndefinitelyOnSTM -> pure ()) $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) >>= \case - Just (Right client) -> closeProtocolServerClient client `catchAll_` pure () - _ -> pure () + E.handle (\BlockedIndefinitelyOnSTM -> pure ()) $ + tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) >>= \case + Just (Right client) -> closeProtocolServerClient client `catchAll_` pure () + _ -> pure () closeXFTPServerClient :: AgentClient -> UserId -> XFTPServer -> FileDigest -> IO () closeXFTPServerClient c userId server (FileDigest chunkDigest) = diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 8e21aada1..4b925c6f6 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -40,6 +40,7 @@ import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BrokerMsg, NotifierId, NtfPrivateAuthKey, ProtocolServer (..), QueueId, RcvPrivateAuthKey, RecipientId, SMPServer) +import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport @@ -50,7 +51,7 @@ import UnliftIO.Exception (Exception) import qualified UnliftIO.Exception as E import UnliftIO.STM -type SMPClientVar = TMVar (Either SMPClientError SMPClient) +type SMPClientVar = SessionVar (Either SMPClientError SMPClient) data SMPClientAgentEvent = CAConnected SMPServer @@ -100,7 +101,8 @@ data SMPClientAgent = SMPClientAgent srvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), pendingSrvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), reconnections :: TVar [Async ()], - asyncClients :: TVar [Async ()] + asyncClients :: TVar [Async ()], + workerSeq :: TVar Int } newtype InternalException e = InternalException {unInternalException :: e} @@ -115,9 +117,10 @@ instance Exception e => MonadUnliftIO (ExceptT e IO) where ExceptT . fmap (first unInternalException) . E.try $ withRunInIO $ \run -> inner $ run . (either (E.throwIO . InternalException) pure <=< runExceptT) - -- as MonadUnliftIO instance for IO is `withRunInIO inner = inner id`, - -- the last two lines could be replaced with: - -- inner $ either (E.throwIO . InternalException) pure <=< runExceptT + +-- as MonadUnliftIO instance for IO is `withRunInIO inner = inner id`, +-- the last two lines could be replaced with: +-- inner $ either (E.throwIO . InternalException) pure <=< runExceptT instance Exception e => MonadUnliftIO (ExceptT e (ReaderT r IO)) where {-# INLINE withRunInIO #-} @@ -136,46 +139,53 @@ newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg pendingSrvSubs <- TM.empty reconnections <- newTVar [] asyncClients <- newTVar [] - pure SMPClientAgent {agentCfg, msgQ, agentQ, randomDrg, smpClients, srvSubs, pendingSrvSubs, reconnections, asyncClients} + workerSeq <- newTVar 0 + pure + SMPClientAgent + { agentCfg, + msgQ, + agentQ, + randomDrg, + smpClients, + srvSubs, + pendingSrvSubs, + reconnections, + asyncClients, + workerSeq + } getSMPServerClient' :: SMPClientAgent -> SMPServer -> ExceptT SMPClientError IO SMPClient -getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg} srv = +getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, workerSeq} srv = atomically getClientVar >>= either newSMPClient waitForSMPClient where getClientVar :: STM (Either SMPClientVar SMPClientVar) - getClientVar = maybe (Left <$> newClientVar) (pure . Right) =<< TM.lookup srv smpClients - - newClientVar :: STM SMPClientVar - newClientVar = do - smpVar <- newEmptyTMVar - TM.insert srv smpVar smpClients - pure smpVar + getClientVar = getSessVar workerSeq srv smpClients waitForSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient - waitForSMPClient smpVar = do + waitForSMPClient v = do let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout}} = smpCfg agentCfg - smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar smpVar) + smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) liftEither $ case smpClient_ of Just (Right smpClient) -> Right smpClient Just (Left e) -> Left e Nothing -> Left PCEResponseTimeout newSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient - newSMPClient smpVar = tryConnectClient pure (liftIO tryConnectAsync) + newSMPClient v = tryConnectClient pure (liftIO tryConnectAsync) where tryConnectClient :: (SMPClient -> ExceptT SMPClientError IO a) -> ExceptT SMPClientError IO () -> ExceptT SMPClientError IO a tryConnectClient successAction retryAction = - tryE connectClient >>= \r -> case r of + tryE (connectClient v) >>= \r -> case r of Right smp -> do logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv - atomically $ putTMVar smpVar r + atomically $ putTMVar (sessionVar v) r successAction smp Left e -> do if e == PCENetworkError || e == PCEResponseTimeout then retryAction else atomically $ do - putTMVar smpVar (Left e) - TM.delete srv smpClients + putTMVar (sessionVar v) (Left e) + removeSessVar v srv smpClients throwE e tryConnectAsync :: IO () tryConnectAsync = do @@ -186,17 +196,17 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg} sr withRetryInterval (reconnectInterval agentCfg) $ \_ loop -> void $ tryConnectClient (const reconnectClient) loop - connectClient :: ExceptT SMPClientError IO SMPClient - connectClient = ExceptT $ getProtocolClient randomDrg (1, srv, Nothing) (smpCfg agentCfg) (Just msgQ) clientDisconnected + connectClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient + connectClient v = ExceptT $ getProtocolClient randomDrg (1, srv, Nothing) (smpCfg agentCfg) (Just msgQ) (clientDisconnected v) - clientDisconnected :: SMPClient -> IO () - clientDisconnected _ = do - removeClientAndSubs >>= (`forM_` serverDown) + clientDisconnected :: SMPClientVar -> SMPClient -> IO () + clientDisconnected v _ = do + removeClientAndSubs v >>= (`forM_` serverDown) logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv - removeClientAndSubs :: IO (Maybe (Map SMPSub C.APrivateAuthKey)) - removeClientAndSubs = atomically $ do - TM.delete srv smpClients + removeClientAndSubs :: SMPClientVar -> IO (Maybe (Map SMPSub C.APrivateAuthKey)) + removeClientAndSubs v = atomically $ do + removeSessVar v srv smpClients TM.lookupDelete srv (srvSubs ca) >>= mapM updateSubs where updateSubs sVar = do @@ -207,7 +217,7 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg} sr addPendingSubs sVar ss = do let ps = pendingSrvSubs ca TM.lookup srv ps >>= \case - Just v -> TM.union ss v + Just ss' -> TM.union ss ss' _ -> TM.insert srv sVar ps serverDown :: Map SMPSub C.APrivateAuthKey -> IO () @@ -268,10 +278,10 @@ closeSMPClientAgent c = do cancelActions $ asyncClients c closeSMPServerClients :: SMPClientAgent -> IO () -closeSMPServerClients c = readTVarIO (smpClients c) >>= mapM_ (forkIO . closeClient) +closeSMPServerClients c = atomically (smpClients c `swapTVar` M.empty) >>= mapM_ (forkIO . closeClient) where - closeClient smpVar = - atomically (readTMVar smpVar) >>= \case + closeClient v = + atomically (readTMVar $ sessionVar v) >>= \case Right smp -> closeProtocolClient smp `catchAll_` pure () _ -> pure () diff --git a/src/Simplex/Messaging/Session.hs b/src/Simplex/Messaging/Session.hs new file mode 100644 index 000000000..7a219e106 --- /dev/null +++ b/src/Simplex/Messaging/Session.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Messaging.Session where + +import Control.Concurrent.STM +import Control.Monad +import Data.Composition ((.:.)) +import Data.Functor (($>)) +import Simplex.Messaging.TMap (TMap) +import qualified Simplex.Messaging.TMap as TM + +data SessionVar a = SessionVar + { sessionVar :: TMVar a, + sessionVarId :: Int + } + +getSessVar :: forall k a. Ord k => TVar Int -> k -> TMap k (SessionVar a) -> STM (Either (SessionVar a) (SessionVar a)) +getSessVar sessSeq sessKey vs = maybe (Left <$> newSessionVar) (pure . Right) =<< TM.lookup sessKey vs + where + newSessionVar :: STM (SessionVar a) + newSessionVar = do + sessionVar <- newEmptyTMVar + sessionVarId <- stateTVar sessSeq $ \next -> (next, next + 1) + let v = SessionVar {sessionVar, sessionVarId} + TM.insert sessKey v vs + pure v + +removeSessVar :: Ord k => SessionVar a -> k -> TMap k (SessionVar a) -> STM () +removeSessVar = void .:. removeSessVar' +{-# INLINE removeSessVar #-} + +removeSessVar' :: Ord k => SessionVar a -> k -> TMap k (SessionVar a) -> STM Bool +removeSessVar' v sessKey vs = + TM.lookup sessKey vs >>= \case + Just v' | sessionVarId v == sessionVarId v' -> TM.delete sessKey vs $> True + _ -> pure False From ad4b5b6b71cc2225cacdcdc75b799c2a6706d037 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 15 Apr 2024 13:47:48 +0100 Subject: [PATCH 006/107] parameterize transport handle with transport peer to include server certificate (#1100) * parameterize transport handle with transport peer to include server certificate * include server certificate into THandle * load server chain and sign key * fix key type * fix for 8.10 --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Co-authored-by: IC Rainbow --- src/Simplex/FileTransfer/Client.hs | 13 ++-- src/Simplex/FileTransfer/Protocol.hs | 12 ++-- src/Simplex/FileTransfer/Server.hs | 16 ++--- src/Simplex/FileTransfer/Transport.hs | 9 +-- src/Simplex/Messaging/Client.hs | 14 ++-- src/Simplex/Messaging/Notifications/Server.hs | 10 +-- .../Messaging/Notifications/Server/Env.hs | 8 +-- .../Messaging/Notifications/Transport.hs | 44 +++++++----- src/Simplex/Messaging/Protocol.hs | 23 +++--- src/Simplex/Messaging/Server.hs | 18 ++--- src/Simplex/Messaging/Transport.hs | 70 ++++++++++++------- tests/CoreTests/BatchingTests.hs | 21 ++++-- tests/NtfClient.hs | 8 +-- tests/NtfServerTests.hs | 6 +- tests/SMPClient.hs | 36 +++++----- tests/SMPProxyTests.hs | 9 +-- tests/ServerTests.hs | 35 +++++----- 17 files changed, 198 insertions(+), 154 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index fcb54aece..a223d492e 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Protocol RecipientId, SenderId, ) -import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), supportedParameters) +import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, alpn) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client @@ -64,7 +64,7 @@ import UnliftIO.Directory data XFTPClient = XFTPClient { http2Client :: HTTP2Client, transportSession :: TransportSession FileResponse, - thParams :: THandleParams XFTPVersion, + thParams :: THandleParams XFTPVersion 'TClient, config :: XFTPClientConfig } @@ -120,19 +120,21 @@ getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN atomically $ writeTVar clientVar $ Just c pure c -xftpClientHandshakeV1 :: TVar ChaChaDRG -> VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP -> ExceptT XFTPClientError IO THandleParamsXFTP +xftpClientHandshakeV1 :: TVar ChaChaDRG -> VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP 'TClient -> ExceptT XFTPClientError IO (THandleParamsXFTP 'TClient) xftpClientHandshakeV1 g serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessionId, serverKey} thParams0 = do - shs <- getServerHandshake + shs@XFTPServerHandshake {authPubKey = ck} <- getServerHandshake (v, sk) <- processServerHandshake shs (k, pk) <- atomically $ C.generateKeyPair g sendClientHandshake XFTPClientHandshake {xftpVersion = v, keyHash, authPubKey = k} - pure thParams0 {thAuth = Just THandleAuth {peerPubKey = sk, privKey = pk}, thVersion = v} + pure thParams0 {thAuth = Just THAuthClient {serverPeerPubKey = sk, serverCertKey = ck, clientPrivKey = pk}, thVersion = v} where + getServerHandshake :: ExceptT XFTPClientError IO XFTPServerHandshake getServerHandshake = do let helloReq = H.requestNoBody "POST" "/" [] HTTP2Response {respBody = HTTP2Body {bodyHead = shsBody}} <- liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c helloReq Nothing liftHS . smpDecode =<< liftHS (C.unPad shsBody) + processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionXFTP, C.PublicKeyX25519) processServerHandshake XFTPServerHandshake {xftpVersionRange, sessionId = serverSessId, authPubKey = serverAuth} = do unless (sessionId == serverSessId) $ throwError $ PCEResponseError SESSION case xftpVersionRange `compatibleVersion` serverVRange of @@ -145,6 +147,7 @@ xftpClientHandshakeV1 g serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessi _ -> throwError "bad certificate" pubKey <- maybe (throwError "bad server key type") (`C.verifyX509` exact) serverKey C.x509ToPublic (pubKey, []) >>= C.pubKey + sendClientHandshake :: XFTPClientHandshake -> ExceptT XFTPClientError IO () sendClientHandshake chs = do chs' <- liftHS $ C.pad (smpEncode chs) xftpBlockSize let chsReq = H.requestBuilder "POST" "/" [] $ byteString chs' diff --git a/src/Simplex/FileTransfer/Protocol.hs b/src/Simplex/FileTransfer/Protocol.hs index f970fdfcc..418e48482 100644 --- a/src/Simplex/FileTransfer/Protocol.hs +++ b/src/Simplex/FileTransfer/Protocol.hs @@ -39,8 +39,8 @@ import Simplex.Messaging.Protocol ProtocolErrorType (..), ProtocolMsgTag (..), ProtocolType (..), - RcvPublicDhKey, RcvPublicAuthKey, + RcvPublicDhKey, RecipientId, SenderId, SentRawTransmission, @@ -48,14 +48,14 @@ import Simplex.Messaging.Protocol SndPublicAuthKey, Transmission, TransmissionForAuth (..), - encodeTransmissionForAuth, encodeTransmission, + encodeTransmissionForAuth, messageTagP, tDecodeParseValidate, tEncodeBatch1, tParse, ) -import Simplex.Messaging.Transport (THandleParams (..), TransportError (..)) +import Simplex.Messaging.Transport (THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Util ((<$?>)) xftpBlockSize :: Int @@ -325,12 +325,12 @@ checkParty' c = case testEquality (sFileParty @p) (sFileParty @p') of Just Refl -> Just c _ -> Nothing -xftpEncodeAuthTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion -> C.APrivateAuthKey -> Transmission c -> Either TransportError ByteString +xftpEncodeAuthTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion 'TClient -> C.APrivateAuthKey -> Transmission c -> Either TransportError ByteString xftpEncodeAuthTransmission thParams@THandleParams {thAuth} pKey (corrId, fId, msg) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, fId, msg) xftpEncodeBatch1 . (,tToSend) =<< authTransmission thAuth (Just pKey) corrId tForAuth -xftpEncodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion -> Transmission c -> Either TransportError ByteString +xftpEncodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion p -> Transmission c -> Either TransportError ByteString xftpEncodeTransmission thParams (corrId, fId, msg) = do let t = encodeTransmission thParams (corrId, fId, msg) xftpEncodeBatch1 (Nothing, t) @@ -339,7 +339,7 @@ xftpEncodeTransmission thParams (corrId, fId, msg) = do xftpEncodeBatch1 :: SentRawTransmission -> Either TransportError ByteString xftpEncodeBatch1 t = first (const TELargeMsg) $ C.pad (tEncodeBatch1 t) xftpBlockSize -xftpDecodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion -> ByteString -> Either XFTPErrorType (SignedTransmission e c) +xftpDecodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion p -> ByteString -> Either XFTPErrorType (SignedTransmission e c) xftpDecodeTransmission thParams t = do t' <- first (const BLOCK) $ C.unPad t case tParse thParams t' of diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index b426360ea..b3bee134f 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -56,7 +56,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.Stats import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (SessionId, THandleAuth (..), THandleParams (..)) +import Simplex.Messaging.Transport (SessionId, THandleAuth (..), THandleParams (..), TransportPeer (..)) import Simplex.Messaging.Transport.Buffer (trimCR) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.File (fileBlockSize) @@ -75,7 +75,7 @@ import qualified UnliftIO.Exception as E type M a = ReaderT XFTPEnv IO a data XFTPTransportRequest = XFTPTransportRequest - { thParams :: THandleParamsXFTP, + { thParams :: THandleParamsXFTP 'TServer, reqBody :: HTTP2Body, request :: H.Request, sendResponse :: H.Response -> IO () @@ -91,7 +91,7 @@ runXFTPServerBlocking started cfg = newXFTPServerEnv cfg >>= runReaderT (xftpSer data Handshake = HandshakeSent C.PrivateKeyX25519 - | HandshakeAccepted THandleAuth VersionXFTP + | HandshakeAccepted (THandleAuth 'TServer) VersionXFTP xftpServer :: XFTPServerConfig -> TMVar Bool -> M () xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration} started = do @@ -120,7 +120,7 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira Nothing -> pure () -- handshake response sent Just thParams -> processRequest req0 {thParams} -- proceed with new version (XXX: may as well switch the request handler here) _ -> liftIO . sendResponse $ H.responseNoBody N.ok200 [] -- shouldn't happen: means server picked handshake protocol it doesn't know about - xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion)) + xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion 'TServer)) xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse} = do s <- atomically $ TM.lookup sessionId sessions r <- runExceptT $ case s of @@ -138,18 +138,18 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira shs <- encodeXftp hs liftIO . sendResponse $ H.responseBuilder N.ok200 [] shs pure Nothing - processClientHandshake privKey = do + processClientHandshake pk = do unless (B.length bodyHead == xftpBlockSize) $ throwError HANDSHAKE body <- liftHS $ C.unPad bodyHead XFTPClientHandshake {xftpVersion, keyHash, authPubKey} <- liftHS $ smpDecode body kh <- asks serverIdentity unless (keyHash == kh) $ throwError HANDSHAKE unless (xftpVersion `isCompatible` supportedFileServerVRange) $ throwError HANDSHAKE - let auth = THandleAuth {peerPubKey = authPubKey, privKey} + let auth = THAuthServer {clientPeerPubKey = authPubKey, serverPrivKey = pk} atomically $ TM.insert sessionId (HandshakeAccepted auth xftpVersion) sessions liftIO . sendResponse $ H.responseNoBody N.ok200 [] pure Nothing - sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion)) + sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion 'TServer)) sendError err = do runExceptT (encodeXftp err) >>= \case Right bs -> liftIO . sendResponse $ H.responseBuilder N.ok200 [] bs @@ -326,7 +326,7 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea data VerificationResult = VRVerified XFTPRequest | VRFailed -verifyXFTPTransmission :: Maybe (THandleAuth, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> XFTPFileId -> FileCmd -> M VerificationResult +verifyXFTPTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> XFTPFileId -> FileCmd -> M VerificationResult verifyXFTPTransmission auth_ tAuth authorized fId cmd = case cmd of FileCmd SFSender (FNEW file rcps auth') -> pure $ XFTPReqNew file rcps auth' `verifyWith` sndKey file diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index d8bb72f6b..041069e98 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} @@ -51,7 +52,7 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol (CommandError) -import Simplex.Messaging.Transport (HandshakeError (..), SessionId, THandle (..), THandleParams (..), TransportError (..)) +import Simplex.Messaging.Transport (HandshakeError (..), SessionId, THandle (..), THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.File import Simplex.Messaging.Util (bshow) import Simplex.Messaging.Version @@ -76,8 +77,8 @@ type VersionRangeXFTP = VersionRange XFTPVersion pattern VersionXFTP :: Word16 -> VersionXFTP pattern VersionXFTP v = Version v -type THandleXFTP c = THandle XFTPVersion c -type THandleParamsXFTP = THandleParams XFTPVersion +type THandleXFTP c p = THandle XFTPVersion c p +type THandleParamsXFTP p = THandleParams XFTPVersion p initialXFTPVersion :: VersionXFTP initialXFTPVersion = VersionXFTP 1 @@ -89,7 +90,7 @@ supportedFileServerVRange :: VersionRangeXFTP supportedFileServerVRange = mkVersionRange initialXFTPVersion currentXFTPVersion -- XFTP protocol does not support handshake -xftpClientHandshakeStub :: c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c) +xftpClientHandshakeStub :: c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c 'TClient) xftpClientHandshakeStub _c _ks _keyHash _xftpVRange = throwError $ TEHandshake VERSION data XFTPServerHandshake = XFTPServerHandshake diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index e0591b14d..61ec96003 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -84,8 +84,8 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception import Control.Monad -import Control.Monad.IO.Class (liftIO) import Control.Monad.Except +import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as J @@ -119,7 +119,7 @@ import System.Timeout (timeout) -- Use 'getSMPClient' to connect to an SMP server and create a client handle. data ProtocolClient v err msg = ProtocolClient { action :: Maybe (Async ()), - thParams :: THandleParams v, + thParams :: THandleParams v 'TClient, sessionTs :: UTCTime, client_ :: PClient v err msg } @@ -138,7 +138,7 @@ data PClient v err msg = PClient msgQ :: Maybe (TBQueue (ServerTransmission v msg)) } -smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe THandleAuth -> STM SMPClient +smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe (THandleAuth 'TClient) -> STM SMPClient smpClientStub g sessionId thVersion thAuth = do connected <- newTVar False clientCorrId <- C.newRandomDRG g @@ -387,10 +387,10 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize raceAny_ ([send c' th, process c', receive c' th] <> [ping c' | smpPingInterval > 0]) `finally` disconnected c' - send :: Transport c => ProtocolClient v err msg -> THandle v c -> IO () + send :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= tPutLog h - receive :: Transport c => ProtocolClient v err msg -> THandle v c -> IO () + receive :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () receive ProtocolClient {client_ = PClient {rcvQ}} h = forever $ tGet h >>= atomically . writeTBQueue rcvQ ping :: ProtocolClient v err msg -> IO () @@ -733,13 +733,13 @@ mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCo TM.insert corrId r sentCommands pure r -authTransmission :: Maybe THandleAuth -> Maybe C.APrivateAuthKey -> CorrId -> ByteString -> Either TransportError (Maybe TransmissionAuth) +authTransmission :: Maybe (THandleAuth 'TClient) -> Maybe C.APrivateAuthKey -> CorrId -> ByteString -> Either TransportError (Maybe TransmissionAuth) authTransmission thAuth pKey_ (CorrId corrId) t = traverse authenticate pKey_ where authenticate :: C.APrivateAuthKey -> Either TransportError TransmissionAuth authenticate (C.APrivateAuthKey a pk) = case a of C.SX25519 -> case thAuth of - Just THandleAuth {peerPubKey} -> Right $ TAAuthenticator $ C.cbAuthenticate peerPubKey pk (C.cbNonce corrId) t + Just THAuthClient {serverPeerPubKey = k} -> Right $ TAAuthenticator $ C.cbAuthenticate k pk (C.cbNonce corrId) t Nothing -> Left TENoServerAuth C.SEd25519 -> sign pk C.SEd448 -> sign pk diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index 55ab40718..55bdb07eb 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -47,7 +47,7 @@ import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Server import Simplex.Messaging.Server.Stats import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport (..), THandle (..), THandleAuth (..), THandleParams (..), TProxy, Transport (..)) +import Simplex.Messaging.Transport (ATransport (..), THandle (..), THandleAuth (..), THandleParams (..), TProxy, Transport (..), TransportPeer (..)) import Simplex.Messaging.Transport.Server (runTransportServer, tlsServerCredentials) import Simplex.Messaging.Util import System.Exit (exitFailure) @@ -339,7 +339,7 @@ updateTknStatus NtfTknData {ntfTknId, tknStatus} status = do old <- atomically $ stateTVar tknStatus (,status) when (old /= status) $ withNtfLog $ \sl -> logTokenStatus sl ntfTknId status -runNtfClientTransport :: Transport c => THandleNTF c -> M () +runNtfClientTransport :: Transport c => THandleNTF c 'TServer -> M () runNtfClientTransport th@THandle {params} = do qSize <- asks $ clientQSize . config ts <- liftIO getSystemTime @@ -356,7 +356,7 @@ runNtfClientTransport th@THandle {params} = do clientDisconnected :: NtfServerClient -> IO () clientDisconnected NtfServerClient {connected} = atomically $ writeTVar connected False -receive :: Transport c => THandleNTF c -> NtfServerClient -> M () +receive :: Transport c => THandleNTF c 'TServer -> NtfServerClient -> M () receive th@THandle {params = THandleParams {thAuth}} NtfServerClient {rcvQ, sndQ, rcvActiveAt} = forever $ do ts <- liftIO $ tGet th forM_ ts $ \t@(_, _, (corrId, entId, cmdOrError)) -> do @@ -371,7 +371,7 @@ receive th@THandle {params = THandleParams {thAuth}} NtfServerClient {rcvQ, sndQ where write q t = atomically $ writeTBQueue q t -send :: Transport c => THandleNTF c -> NtfServerClient -> IO () +send :: Transport c => THandleNTF c 'TServer -> NtfServerClient -> IO () send h@THandle {params} NtfServerClient {sndQ, sndActiveAt} = forever $ do t <- atomically $ readTBQueue sndQ void . liftIO $ tPut h [Right (Nothing, encodeTransmission params t)] @@ -382,7 +382,7 @@ send h@THandle {params} NtfServerClient {sndQ, sndActiveAt} = forever $ do data VerificationResult = VRVerified NtfRequest | VRFailed -verifyNtfTransmission :: Maybe (THandleAuth, C.CbNonce) -> SignedTransmission ErrorType NtfCmd -> NtfCmd -> M VerificationResult +verifyNtfTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> SignedTransmission ErrorType NtfCmd -> NtfCmd -> M VerificationResult verifyNtfTransmission auth_ (tAuth, authorized, (corrId, entId, _)) cmd = do st <- asks store case cmd of diff --git a/src/Simplex/Messaging/Notifications/Server/Env.hs b/src/Simplex/Messaging/Notifications/Server/Env.hs index 4a93a8a34..5bcd72f3d 100644 --- a/src/Simplex/Messaging/Notifications/Server/Env.hs +++ b/src/Simplex/Messaging/Notifications/Server/Env.hs @@ -24,16 +24,16 @@ import Numeric.Natural import Simplex.Messaging.Client.Agent import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol -import Simplex.Messaging.Notifications.Transport (NTFVersion, VersionRangeNTF) import Simplex.Messaging.Notifications.Server.Push.APNS import Simplex.Messaging.Notifications.Server.Stats import Simplex.Messaging.Notifications.Server.Store import Simplex.Messaging.Notifications.Server.StoreLog +import Simplex.Messaging.Notifications.Transport (NTFVersion, VersionRangeNTF) import Simplex.Messaging.Protocol (CorrId, SMPServer, Transmission) import Simplex.Messaging.Server.Expiration import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport, THandleParams) +import Simplex.Messaging.Transport (ATransport, THandleParams, TransportPeer (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig, loadFingerprint, loadTLSServerParams) import System.IO (IOMode (..)) import System.Mem.Weak (Weak) @@ -161,13 +161,13 @@ data NtfRequest data NtfServerClient = NtfServerClient { rcvQ :: TBQueue NtfRequest, sndQ :: TBQueue (Transmission NtfResponse), - ntfThParams :: THandleParams NTFVersion, + ntfThParams :: THandleParams NTFVersion 'TServer, connected :: TVar Bool, rcvActiveAt :: TVar SystemTime, sndActiveAt :: TVar SystemTime } -newNtfServerClient :: Natural -> THandleParams NTFVersion -> SystemTime -> STM NtfServerClient +newNtfServerClient :: Natural -> THandleParams NTFVersion 'TServer -> SystemTime -> STM NtfServerClient newNtfServerClient qSize ntfThParams ts = do rcvQ <- newTBQueue qSize sndQ <- newTBQueue qSize diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index bc68fab03..d4a4a4cbe 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -5,6 +5,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} module Simplex.Messaging.Notifications.Transport where @@ -18,9 +19,9 @@ import qualified Data.X509 as X import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Transport +import Simplex.Messaging.Util (liftEitherWith) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal -import Simplex.Messaging.Util (liftEitherWith) ntfBlockSize :: Int ntfBlockSize = 512 @@ -54,7 +55,7 @@ supportedClientNTFVRange = mkVersionRange initialNTFVersion currentClientNTFVers supportedServerNTFVRange :: VersionRangeNTF supportedServerNTFVRange = mkVersionRange initialNTFVersion currentServerNTFVersion -type THandleNTF c = THandle NTFVersion c +type THandleNTF c p = THandle NTFVersion c p data NtfServerHandshake = NtfServerHandshake { ntfVersionRange :: VersionRangeNTF, @@ -111,7 +112,7 @@ encodeNtfAuthPubKey v k | otherwise = "" -- | Notifcations server transport handshake. -ntfServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c) +ntfServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TServer) ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do let th@THandle {params = THandleParams {sessionId}} = ntfTHandle c let sk = C.signX509 serverSignKey $ C.publicToX509 k @@ -121,11 +122,11 @@ ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do | keyHash /= kh -> throwError $ TEHandshake IDENTITY | v `isCompatible` ntfVRange -> - pure $ ntfThHandle th v pk k' + pure $ ntfThHandleServer th v pk k' | otherwise -> throwError $ TEHandshake VERSION -- | Notifcations server client transport handshake. -ntfClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c) +ntfClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TClient) ntfClientHandshake c (k, pk) keyHash ntfVRange = do let th@THandle {params = THandleParams {sessionId}} = ntfTHandle c NtfServerHandshake {sessionId = sessId, ntfVersionRange, authPubKey = sk'} <- getHandshake th @@ -133,23 +134,32 @@ ntfClientHandshake c (k, pk) keyHash ntfVRange = do then throwError TEBadSession else case ntfVersionRange `compatibleVersion` ntfVRange of Just (Compatible v) -> do - sk_ <- forM sk' $ \exact -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do + ck_ <- forM sk' $ \signedKey -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do serverKey <- getServerVerifyKey c - pubKey <- C.verifyX509 serverKey exact - C.x509ToPublic (pubKey, []) >>= C.pubKey + pubKey <- C.verifyX509 serverKey signedKey + (,(getServerCerts c, signedKey)) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) sendHandshake th $ NtfClientHandshake {ntfVersion = v, keyHash, authPubKey = Just k} - pure $ ntfThHandle th v pk sk_ + pure $ ntfThHandleClient th v pk ck_ Nothing -> throwError $ TEHandshake VERSION -ntfThHandle :: forall c. THandleNTF c -> VersionNTF -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleNTF c -ntfThHandle th@THandle {params} v privKey k_ = - -- TODO drop SMP v6: make thAuth non-optional - let thAuth = (\k -> THandleAuth {peerPubKey = k, privKey}) <$> k_ - v3 = v >= authBatchCmdsNTFVersion - params' = params {thVersion = v, thAuth, implySessId = v3, batch = v3} - in (th :: THandleNTF c) {params = params'} +ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleNTF c 'TServer +ntfThHandleServer th v pk k_ = + let thAuth = (\k -> THAuthServer {clientPeerPubKey = k, serverPrivKey = pk}) <$> k_ + in ntfThHandle_ th v thAuth -ntfTHandle :: Transport c => c -> THandleNTF c +ntfThHandleClient :: forall c. THandleNTF c 'TClient -> VersionNTF -> C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleNTF c 'TClient +ntfThHandleClient th v pk ck_ = + let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, clientPrivKey = pk}) <$> ck_ + in ntfThHandle_ th v thAuth + +ntfThHandle_ :: forall c p. THandleNTF c p -> VersionNTF -> Maybe (THandleAuth p) -> THandleNTF c p +ntfThHandle_ th@THandle {params} v thAuth = + -- TODO drop SMP v6: make thAuth non-optional + let v3 = v >= authBatchCmdsNTFVersion + params' = params {thVersion = v, thAuth, implySessId = v3, batch = v3} + in (th :: THandleNTF c p) {params = params'} + +ntfTHandle :: Transport c => c -> THandleNTF c p ntfTHandle c = THandle {connection = c, params} where params = THandleParams {sessionId = tlsUnique c, blockSize = ntfBlockSize, thVersion = VersionNTF 0, thAuth = Nothing, implySessId = False, batch = False} diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 77b14e23c..b5a3e7770 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1135,7 +1135,7 @@ data CommandError deriving (Eq, Read, Show) -- | SMP transmission parser. -transmissionP :: THandleParams v -> Parser RawTransmission +transmissionP :: THandleParams v p -> Parser RawTransmission transmissionP THandleParams {sessionId, implySessId} = do authenticator <- smpP authorized <- A.takeByteString @@ -1149,10 +1149,10 @@ transmissionP THandleParams {sessionId, implySessId} = do command <- A.takeByteString pure RawTransmission {authenticator, authorized = authorized', sessId, corrId, entityId, command} -class (ProtocolEncoding v err msg, ProtocolEncoding v err (ProtoCommand msg), Show err, Show msg) => Protocol v err msg | msg -> v, msg -> err where +class (ProtocolEncoding v err msg, ProtocolEncoding v err (ProtoCommand msg), Show err, Show msg) => Protocol v err msg | msg -> v, msg -> err where type ProtoCommand msg = cmd | cmd -> msg type ProtoType msg = (sch :: ProtocolType) | sch -> msg - protocolClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRange v -> ExceptT TransportError IO (THandle v c) + protocolClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRange v -> ExceptT TransportError IO (THandle v c 'TClient) protocolPing :: ProtoCommand msg protocolError :: msg -> Maybe err @@ -1258,7 +1258,6 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where PFWD_ -> error "TODO: PFWD_" RFWD_ -> error "TODO: RFWD_" PRXY_ -> PRXY <$> (_smpP >>= either fail pure . strDecode) <*> _smpP - CT SNotifier NSUB_ -> pure $ Cmd SNotifier NSUB fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg @@ -1386,7 +1385,7 @@ instance Encoding CommandError where _ -> fail "bad command error type" -- | Send signed SMP transmission to TCP transport. -tPut :: Transport c => THandle v c -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] +tPut :: Transport c => THandle v c p -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] tPut th@THandle {params} = fmap concat . mapM tPutBatch . batchTransmissions (batch params) (blockSize params) where tPutBatch :: TransportBatch () -> IO [Either TransportError ()] @@ -1395,7 +1394,7 @@ tPut th@THandle {params} = fmap concat . mapM tPutBatch . batchTransmissions (ba TBTransmissions s n _ -> replicate n <$> tPutLog th s TBTransmission s _ -> (: []) <$> tPutLog th s -tPutLog :: Transport c => THandle v c -> ByteString -> IO (Either TransportError ()) +tPutLog :: Transport c => THandle v c p -> ByteString -> IO (Either TransportError ()) tPutLog th s = do r <- tPutBlock th s case r of @@ -1461,7 +1460,7 @@ tEncodeBatch1 t = lenEncode 1 `B.cons` tEncodeForBatch t -- tForAuth is lazy to avoid computing it when there is no key to sign data TransmissionForAuth = TransmissionForAuth {tForAuth :: ~ByteString, tToSend :: ByteString} -encodeTransmissionForAuth :: ProtocolEncoding v e c => THandleParams v -> Transmission c -> TransmissionForAuth +encodeTransmissionForAuth :: ProtocolEncoding v e c => THandleParams v p -> Transmission c -> TransmissionForAuth encodeTransmissionForAuth THandleParams {thVersion = v, sessionId, implySessId} t = TransmissionForAuth {tForAuth, tToSend = if implySessId then t' else tForAuth} where @@ -1469,7 +1468,7 @@ encodeTransmissionForAuth THandleParams {thVersion = v, sessionId, implySessId} t' = encodeTransmission_ v t {-# INLINE encodeTransmissionForAuth #-} -encodeTransmission :: ProtocolEncoding v e c => THandleParams v -> Transmission c -> ByteString +encodeTransmission :: ProtocolEncoding v e c => THandleParams v p -> Transmission c -> ByteString encodeTransmission THandleParams {thVersion = v, sessionId, implySessId} t = if implySessId then t' else smpEncode sessionId <> t' where @@ -1482,11 +1481,11 @@ encodeTransmission_ v (CorrId corrId, queueId, command) = {-# INLINE encodeTransmission_ #-} -- | Receive and parse transmission from the TCP transport (ignoring any trailing padding). -tGetParse :: Transport c => THandle v c -> IO (NonEmpty (Either TransportError RawTransmission)) +tGetParse :: Transport c => THandle v c p -> IO (NonEmpty (Either TransportError RawTransmission)) tGetParse th@THandle {params} = eitherList (tParse params) <$> tGetBlock th {-# INLINE tGetParse #-} -tParse :: THandleParams v -> ByteString -> NonEmpty (Either TransportError RawTransmission) +tParse :: THandleParams v p -> ByteString -> NonEmpty (Either TransportError RawTransmission) tParse thParams@THandleParams {batch} s | batch = eitherList (L.map (\(Large t) -> tParse1 t)) ts | otherwise = [tParse1 s] @@ -1498,10 +1497,10 @@ eitherList :: (a -> NonEmpty (Either e b)) -> Either e a -> NonEmpty (Either e b eitherList = either (\e -> [Left e]) -- | Receive client and server transmissions (determined by `cmd` type). -tGet :: forall v err cmd c. (ProtocolEncoding v err cmd, Transport c) => THandle v c -> IO (NonEmpty (SignedTransmission err cmd)) +tGet :: forall v err cmd c p. (ProtocolEncoding v err cmd, Transport c) => THandle v c p -> IO (NonEmpty (SignedTransmission err cmd)) tGet th@THandle {params} = L.map (tDecodeParseValidate params) <$> tGetParse th -tDecodeParseValidate :: forall v err cmd. ProtocolEncoding v err cmd => THandleParams v -> Either TransportError RawTransmission -> SignedTransmission err cmd +tDecodeParseValidate :: forall v p err cmd. ProtocolEncoding v err cmd => THandleParams v p -> Either TransportError RawTransmission -> SignedTransmission err cmd tDecodeParseValidate THandleParams {sessionId, thVersion = v, implySessId} = \case Right RawTransmission {authenticator, authorized, sessId, corrId, entityId, command} | implySessId || sessId == sessionId -> diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 456de5be2..2ff0d5e51 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -409,7 +409,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do logError "Unauthorized control port command" hPutStrLn h "AUTH" -runClientTransport :: Transport c => THandleSMP c -> M () +runClientTransport :: Transport c => THandleSMP c 'TServer -> M () runClientTransport th@THandle {params = THandleParams {thVersion, sessionId}} = do q <- asks $ tbqSize . config ts <- liftIO getSystemTime @@ -457,7 +457,7 @@ cancelSub sub = Sub {subThread = SubThread t} -> liftIO $ deRefWeak t >>= mapM_ killThread _ -> return () -receive :: Transport c => THandleSMP c -> Client -> M () +receive :: Transport c => THandleSMP c 'TServer -> Client -> M () receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActiveAt, sessionId} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " receive" forever $ do @@ -478,7 +478,7 @@ receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActi VRFailed -> Left (corrId, queueId, ERR AUTH) write q = mapM_ (atomically . writeTBQueue q) . L.nonEmpty -send :: Transport c => THandleSMP c -> Client -> IO () +send :: Transport c => THandleSMP c 'TServer -> Client -> IO () send h@THandle {params} Client {sndQ, sessionId, sndActiveAt} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " send" forever $ do @@ -493,7 +493,7 @@ send h@THandle {params} Client {sndQ, sessionId, sndActiveAt} = do NMSG {} -> 0 _ -> 1 -disconnectTransport :: Transport c => THandle v c -> TVar SystemTime -> TVar SystemTime -> ExpirationConfig -> IO Bool -> IO () +disconnectTransport :: Transport c => THandle v c 'TServer -> TVar SystemTime -> TVar SystemTime -> ExpirationConfig -> IO Bool -> IO () disconnectTransport THandle {connection, params = THandleParams {sessionId}} rcvActiveAt sndActiveAt expCfg noSubscriptions = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " disconnectTransport" loop @@ -514,7 +514,7 @@ data VerificationResult = VRVerified (Maybe QueueRec) | VRFailed -- - the queue or party key do not exist. -- In all cases, the time of the verification should depend only on the provided authorization type, -- a dummy key is used to run verification in the last two cases, and failure is returned irrespective of the result. -verifyTransmission :: Maybe (THandleAuth, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> QueueId -> Cmd -> M VerificationResult +verifyTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> QueueId -> Cmd -> M VerificationResult verifyTransmission auth_ tAuth authorized queueId cmd = case cmd of Cmd SRecipient (NEW k _ _ _) -> pure $ Nothing `verifiedWith` k @@ -539,7 +539,7 @@ verifyTransmission auth_ tAuth authorized queueId cmd = st <- asks queueStore atomically $ getQueue st party queueId -verifyCmdAuthorization :: Maybe (THandleAuth, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> C.APublicAuthKey -> Bool +verifyCmdAuthorization :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> C.APublicAuthKey -> Bool verifyCmdAuthorization auth_ tAuth authorized key = maybe False (verify key) tAuth where verify :: C.APublicAuthKey -> TransmissionAuth -> Bool @@ -551,12 +551,12 @@ verifyCmdAuthorization auth_ tAuth authorized key = maybe False (verify key) tAu C.SX25519 -> verifyCmdAuth auth_ k s authorized _ -> verifyCmdAuth auth_ dummyKeyX25519 s authorized `seq` False -verifyCmdAuth :: Maybe (THandleAuth, C.CbNonce) -> C.PublicKeyX25519 -> C.CbAuthenticator -> ByteString -> Bool +verifyCmdAuth :: Maybe (THandleAuth 'TServer, C.CbNonce) -> C.PublicKeyX25519 -> C.CbAuthenticator -> ByteString -> Bool verifyCmdAuth auth_ k authenticator authorized = case auth_ of - Just (THandleAuth {privKey}, nonce) -> C.cbVerify k privKey nonce authenticator authorized + Just (THAuthServer {serverPrivKey = pk}, nonce) -> C.cbVerify k pk nonce authenticator authorized Nothing -> False -dummyVerifyCmd :: Maybe (THandleAuth, C.CbNonce) -> ByteString -> TransmissionAuth -> Bool +dummyVerifyCmd :: Maybe (THandleAuth 'TServer, C.CbNonce) -> ByteString -> TransmissionAuth -> Bool dummyVerifyCmd auth_ authorized = \case TASignature (C.ASignature a s) -> C.verify' (dummySignKey a) s authorized TAAuthenticator s -> verifyCmdAuth auth_ dummyKeyX25519 s authorized diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 3d4916b92..565199c36 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -12,6 +12,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} -- | @@ -315,20 +316,20 @@ instance Transport TLS where -- * SMP transport -- | The handle for SMP encrypted transport connection over Transport. -data THandle v c = THandle +data THandle v c p = THandle { connection :: c, - params :: THandleParams v + params :: THandleParams v p } -type THandleSMP c = THandle SMPVersion c +type THandleSMP c p = THandle SMPVersion c p -data THandleParams v = THandleParams +data THandleParams v p = THandleParams { sessionId :: SessionId, blockSize :: Int, -- | agreed server protocol version thVersion :: Version v, -- | peer public key for command authorization and shared secrets for entity ID encryption - thAuth :: Maybe THandleAuth, + thAuth :: Maybe (THandleAuth p), -- | do NOT send session ID in transmission, but include it into signed message -- based on protocol version implySessId :: Bool, @@ -337,10 +338,18 @@ data THandleParams v = THandleParams batch :: Bool } -data THandleAuth = THandleAuth - { peerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key - privKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) - } +data THandleAuth (p :: TransportPeer) where + THAuthClient :: + { serverPeerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key + serverCertKey :: (X.CertificateChain, X.SignedExact X.PubKey), -- the key here is clientPrivKey signed with server certificate + clientPrivKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) + } -> + THandleAuth 'TClient + THAuthServer :: + { clientPeerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key + serverPrivKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) + } -> + THandleAuth 'TServer -- | TLS-unique channel binding type SessionId = ByteString @@ -442,13 +451,13 @@ serializeTransportError = \case TEHandshake e -> "HANDSHAKE " <> bshow e -- | Pad and send block to SMP transport. -tPutBlock :: Transport c => THandle v c -> ByteString -> IO (Either TransportError ()) +tPutBlock :: Transport c => THandle v c p -> ByteString -> IO (Either TransportError ()) tPutBlock THandle {connection = c, params = THandleParams {blockSize}} block = bimapM (const $ pure TELargeMsg) (cPut c) $ C.pad block blockSize -- | Receive block from SMP transport. -tGetBlock :: Transport c => THandle v c -> IO (Either TransportError ByteString) +tGetBlock :: Transport c => THandle v c p -> IO (Either TransportError ByteString) tGetBlock THandle {connection = c, params = THandleParams {blockSize}} = do msg <- cGet c blockSize if B.length msg == blockSize @@ -458,7 +467,7 @@ tGetBlock THandle {connection = c, params = THandleParams {blockSize}} = do -- | Server SMP transport handshake. -- -- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a -smpServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c) +smpServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c 'TServer) smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do let th@THandle {params = THandleParams {sessionId}} = smpTHandle c sk = C.signX509 serverSignKey $ C.publicToX509 k @@ -469,13 +478,13 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do | keyHash /= kh -> throwE $ TEHandshake IDENTITY | v `isCompatible` smpVRange -> - pure $ smpThHandle th v pk k' + pure $ smpThHandleServer th v pk k' | otherwise -> throwE $ TEHandshake VERSION -- | Client SMP transport handshake. -- -- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a -smpClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c) +smpClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c 'TClient) smpClientHandshake c (k, pk) keyHash@(C.KeyHash kh) smpVRange = do let th@THandle {params = THandleParams {sessionId}} = smpTHandle c ServerHandshake {sessionId = sessId, smpVersionRange, authPubKey} <- getHandshake th @@ -483,33 +492,42 @@ smpClientHandshake c (k, pk) keyHash@(C.KeyHash kh) smpVRange = do then throwE TEBadSession else case smpVersionRange `compatibleVersion` smpVRange of Just (Compatible v) -> do - sk_ <- forM authPubKey $ \(X.CertificateChain cert, exact) -> + ck_ <- forM authPubKey $ \certKey@(X.CertificateChain cert, exact) -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do case cert of [_leaf, ca] | XV.Fingerprint kh == XV.getFingerprint ca X.HashSHA256 -> pure () _ -> throwError "bad certificate" serverKey <- getServerVerifyKey c pubKey <- C.verifyX509 serverKey exact - C.x509ToPublic (pubKey, []) >>= C.pubKey + (,certKey) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = Just k} - pure $ smpThHandle th v pk sk_ + pure $ smpThHandleClient th v pk ck_ Nothing -> throwE $ TEHandshake VERSION -smpThHandle :: forall c. THandleSMP c -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c -smpThHandle th@THandle {params} v privKey k_ = - -- TODO drop SMP v6: make thAuth non-optional - let thAuth = (\k -> THandleAuth {peerPubKey = k, privKey}) <$> k_ - params' = params {thVersion = v, thAuth, implySessId = v >= authCmdsSMPVersion} - in (th :: THandleSMP c) {params = params'} +smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer +smpThHandleServer th v pk k_ = + let thAuth = (\k -> THAuthServer {clientPeerPubKey = k, serverPrivKey = pk}) <$> k_ + in smpThHandle_ th v thAuth -sendHandshake :: (Transport c, Encoding smp) => THandle v c -> smp -> ExceptT TransportError IO () +smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient +smpThHandleClient th v pk ck_ = + let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, clientPrivKey = pk}) <$> ck_ + in smpThHandle_ th v thAuth + +smpThHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> Maybe (THandleAuth p) -> THandleSMP c p +smpThHandle_ th@THandle {params} v thAuth = + -- TODO drop SMP v6: make thAuth non-optional + let params' = params {thVersion = v, thAuth, implySessId = v >= authCmdsSMPVersion} + in (th :: THandleSMP c p) {params = params'} + +sendHandshake :: (Transport c, Encoding smp) => THandle v c p -> smp -> ExceptT TransportError IO () sendHandshake th = ExceptT . tPutBlock th . smpEncode -- ignores tail bytes to allow future extensions -getHandshake :: (Transport c, Encoding smp) => THandle v c -> ExceptT TransportError IO smp +getHandshake :: (Transport c, Encoding smp) => THandle v c p -> ExceptT TransportError IO smp getHandshake th = ExceptT $ (first (\_ -> TEHandshake PARSE) . A.parseOnly smpP =<<) <$> tGetBlock th -smpTHandle :: Transport c => c -> THandleSMP c +smpTHandle :: Transport c => c -> THandleSMP c p smpTHandle c = THandle {connection = c, params} where params = THandleParams {sessionId = tlsUnique c, blockSize = smpBlockSize, thVersion = VersionSMP 0, thAuth = Nothing, implySessId = False, batch = True} diff --git a/tests/CoreTests/BatchingTests.hs b/tests/CoreTests/BatchingTests.hs index 996d2fed1..c349096dc 100644 --- a/tests/CoreTests/BatchingTests.hs +++ b/tests/CoreTests/BatchingTests.hs @@ -1,7 +1,9 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} module CoreTests.BatchingTests (batchingTests) where @@ -11,6 +13,9 @@ import Crypto.Random (ChaChaDRG) import qualified Data.ByteString as B import Data.ByteString.Char8 (ByteString) import qualified Data.List.NonEmpty as L +import qualified Data.X509 as X +import qualified Data.X509.CertificateStore as XS +import qualified Data.X509.File as XF import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol @@ -314,7 +319,7 @@ randomSEND_ a v sessId len = do TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, sId, Cmd SSender $ SEND noMsgFlags msg) pure $ (,tToSend) <$> authTransmission thAuth_ (Just spKey) corrId tForAuth -testTHandleParams :: VersionSMP -> ByteString -> THandleParams SMPVersion +testTHandleParams :: VersionSMP -> ByteString -> THandleParams SMPVersion 'TClient testTHandleParams v sessionId = THandleParams { sessionId, @@ -325,11 +330,17 @@ testTHandleParams v sessionId = batch = True } -testTHandleAuth :: VersionSMP -> TVar ChaChaDRG -> C.APublicAuthKey -> IO (Maybe THandleAuth) -testTHandleAuth v g (C.APublicAuthKey a k) = case a of +testTHandleAuth :: VersionSMP -> TVar ChaChaDRG -> C.APublicAuthKey -> IO (Maybe (THandleAuth 'TClient)) +testTHandleAuth v g (C.APublicAuthKey a serverPeerPubKey) = case a of C.SX25519 | v >= authCmdsSMPVersion -> do - (_, privKey) <- atomically $ C.generateKeyPair g - pure $ Just THandleAuth {peerPubKey = k, privKey} + (_, clientPrivKey) <- atomically $ C.generateKeyPair @'C.X25519 g + ca <- head <$> XS.readCertificates "tests/fixtures/ca.crt" + serverCert <- head <$> XS.readCertificates "tests/fixtures/server.crt" + serverKey <- head <$> XF.readKeyFile "tests/fixtures/server.key" + signKey <- either error pure $ C.x509ToPrivate (serverKey, []) >>= C.privKey @C.APrivateSignKey + (serverAuthPub, _) <- atomically $ C.generateKeyPair @'C.X25519 g + let serverCertKey = (X.CertificateChain [serverCert, ca], C.signX509 signKey $ C.toPubKey C.publicToX509 serverAuthPub) + pure $ Just THAuthClient {serverPeerPubKey, serverCertKey, clientPrivKey} _ -> pure Nothing randomSENDCmd :: ProtocolClient SMPVersion ErrorType BrokerMsg -> Int -> IO (PCTransmission ErrorType BrokerMsg) diff --git a/tests/NtfClient.hs b/tests/NtfClient.hs index 26981628f..d47722944 100644 --- a/tests/NtfClient.hs +++ b/tests/NtfClient.hs @@ -70,7 +70,7 @@ testKeyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=" ntfTestStoreLogFile :: FilePath ntfTestStoreLogFile = "tests/tmp/ntf-server-store.log" -testNtfClient :: Transport c => (THandleNTF c -> IO a) -> IO a +testNtfClient :: Transport c => (THandleNTF c 'TClient -> IO a) -> IO a testNtfClient client = do Right host <- pure $ chooseTransportHost defaultNetworkConfig testHost runTransportClient defaultTransportClientConfig Nothing host ntfTestPort (Just testKeyHash) $ \h -> do @@ -139,7 +139,7 @@ withNtfServerOn t port' = withNtfServerThreadOn t port' . const withNtfServer :: ATransport -> IO a -> IO a withNtfServer t = withNtfServerOn t ntfTestPort -runNtfTest :: forall c a. Transport c => (THandleNTF c -> IO a) -> IO a +runNtfTest :: forall c a. Transport c => (THandleNTF c 'TClient -> IO a) -> IO a runNtfTest test = withNtfServer (transport @c) $ testNtfClient test ntfServerTest :: @@ -150,7 +150,7 @@ ntfServerTest :: IO (Maybe TransmissionAuth, ByteString, ByteString, NtfResponse) ntfServerTest _ t = runNtfTest $ \h -> tPut' h t >> tGet' h where - tPut' :: THandleNTF c -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () + tPut' :: THandleNTF c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () tPut' h@THandle {params = THandleParams {sessionId, implySessId}} (sig, corrId, queueId, smp) = do let t' = if implySessId then smpEncode (corrId, queueId, smp) else smpEncode (sessionId, corrId, queueId, smp) [Right ()] <- tPut h [Right (sig, t')] @@ -159,7 +159,7 @@ ntfServerTest _ t = runNtfTest $ \h -> tPut' h t >> tGet' h [(Nothing, _, (CorrId corrId, qId, Right cmd))] <- tGet h pure (Nothing, corrId, qId, cmd) -ntfTest :: Transport c => TProxy c -> (THandleNTF c -> IO ()) -> Expectation +ntfTest :: Transport c => TProxy c -> (THandleNTF c 'TClient -> IO ()) -> Expectation ntfTest _ test' = runNtfTest test' `shouldReturn` () data APNSMockRequest = APNSMockRequest diff --git a/tests/NtfServerTests.hs b/tests/NtfServerTests.hs index e7e2018c2..e5f096eef 100644 --- a/tests/NtfServerTests.hs +++ b/tests/NtfServerTests.hs @@ -6,8 +6,8 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} {-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module NtfServerTests where @@ -72,13 +72,13 @@ pattern RespNtf corrId queueId command <- (_, _, (corrId, queueId, Right command deriving instance Eq NtfResponse -sendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c -> (Maybe TransmissionAuth, ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) +sendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) sendRecvNtf h@THandle {params} (sgn, corrId, qId, cmd) = do let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (sgn, tToSend) tGet1 h -signSendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c -> C.APrivateAuthKey -> (ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) +signSendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c 'TClient -> C.APrivateAuthKey -> (ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) signSendRecvNtf h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (authorize tForAuth, tToSend) diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index b63668f7a..cbdf4319d 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -67,15 +67,15 @@ xit'' d t = do ci <- runIO $ lookupEnv "CI" (if ci == Just "true" then skip "skipped on CI" . it d else it d) t -testSMPClient :: Transport c => (THandleSMP c -> IO a) -> IO a +testSMPClient :: Transport c => (THandleSMP c 'TClient -> IO a) -> IO a testSMPClient = testSMPClientVR supportedClientSMPRelayVRange -testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c -> IO a) -> IO a +testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a testSMPClientVR vr client = do Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost testSMPClient_ useHost testPort vr client -testSMPClient_ :: Transport c => TransportHost -> ServiceName -> VersionRangeSMP -> (THandleSMP c -> IO a) -> IO a +testSMPClient_ :: Transport c => TransportHost -> ServiceName -> VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a testSMPClient_ host port vr client = do runTransportClient defaultTransportClientConfig Nothing host port (Just testKeyHash) $ \h -> do g <- C.newRandom @@ -119,7 +119,7 @@ cfgV7 :: ServerConfig cfgV7 = cfg {smpServerVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} proxyCfg :: ServerConfig -proxyCfg = cfg { allowSMPProxy = True } +proxyCfg = cfg {allowSMPProxy = True} withSmpServerStoreMsgLogOn :: HasCallStack => ATransport -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a withSmpServerStoreMsgLogOn t = withSmpServerConfigOn t cfg {storeLogFile = Just testStoreLogFile, storeMsgsFile = Just testStoreMsgsFile, serverStatsBackupFile = Just testServerStatsBackupFile} @@ -158,16 +158,16 @@ withSmpServer t = withSmpServerOn t testPort withSmpServerV7 :: HasCallStack => ATransport -> IO a -> IO a withSmpServerV7 t = withSmpServerConfigOn t cfgV7 testPort . const -runSmpTest :: forall c a. (HasCallStack, Transport c) => (HasCallStack => THandleSMP c -> IO a) -> IO a +runSmpTest :: forall c a. (HasCallStack, Transport c) => (HasCallStack => THandleSMP c 'TClient -> IO a) -> IO a runSmpTest test = withSmpServer (transport @c) $ testSMPClient test -runSmpTestN :: forall c a. (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c] -> IO a) -> IO a +runSmpTestN :: forall c a. (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c 'TClient] -> IO a) -> IO a runSmpTestN = runSmpTestNCfg cfg supportedClientSMPRelayVRange -runSmpTestNCfg :: forall c a. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> Int -> (HasCallStack => [THandleSMP c] -> IO a) -> IO a +runSmpTestNCfg :: forall c a. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> Int -> (HasCallStack => [THandleSMP c 'TClient] -> IO a) -> IO a runSmpTestNCfg srvCfg clntVR nClients test = withSmpServerConfigOn (transport @c) srvCfg testPort $ \_ -> run nClients [] where - run :: Int -> [THandleSMP c] -> IO a + run :: Int -> [THandleSMP c 'TClient] -> IO a run 0 hs = test hs run n hs = testSMPClientVR clntVR $ \h -> run (n - 1) (h : hs) @@ -179,7 +179,7 @@ smpServerTest :: IO (Maybe TransmissionAuth, ByteString, ByteString, BrokerMsg) smpServerTest _ t = runSmpTest $ \h -> tPut' h t >> tGet' h where - tPut' :: THandleSMP c -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () + tPut' :: THandleSMP c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () tPut' h@THandle {params = THandleParams {sessionId, implySessId}} (sig, corrId, queueId, smp) = do let t' = if implySessId then smpEncode (corrId, queueId, smp) else smpEncode (sessionId, corrId, queueId, smp) [Right ()] <- tPut h [Right (sig, t')] @@ -188,33 +188,33 @@ smpServerTest _ t = runSmpTest $ \h -> tPut' h t >> tGet' h [(Nothing, _, (CorrId corrId, qId, Right cmd))] <- tGet h pure (Nothing, corrId, qId, cmd) -smpTest :: (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> IO ()) -> Expectation +smpTest :: (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> IO ()) -> Expectation smpTest _ test' = runSmpTest test' `shouldReturn` () -smpTestN :: (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c] -> IO ()) -> Expectation +smpTestN :: (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c 'TClient] -> IO ()) -> Expectation smpTestN n test' = runSmpTestN n test' `shouldReturn` () -smpTest2 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest2 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest2 = smpTest2Cfg cfg supportedClientSMPRelayVRange -smpTest2Cfg :: forall c. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest2Cfg :: forall c. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest2Cfg srvCfg clntVR _ test' = runSmpTestNCfg srvCfg clntVR 2 _test `shouldReturn` () where - _test :: HasCallStack => [THandleSMP c] -> IO () + _test :: HasCallStack => [THandleSMP c 'TClient] -> IO () _test [h1, h2] = test' h1 h2 _test _ = error "expected 2 handles" -smpTest3 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest3 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest3 _ test' = smpTestN 3 _test where - _test :: HasCallStack => [THandleSMP c] -> IO () + _test :: HasCallStack => [THandleSMP c 'TClient] -> IO () _test [h1, h2, h3] = test' h1 h2 h3 _test _ = error "expected 3 handles" -smpTest4 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest4 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest4 _ test' = smpTestN 4 _test where - _test :: HasCallStack => [THandleSMP c] -> IO () + _test :: HasCallStack => [THandleSMP c 'TClient] -> IO () _test [h1, h2, h3, h4] = test' h1 h2 h3 h4 _test _ = error "expected 4 handles" diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 0a429fc57..6cdf1f590 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -5,6 +6,7 @@ module SMPProxyTests where +import Debug.Trace import SMPAgentClient (testSMPServer, testSMPServer2) import SMPClient import ServerTests (sendRecv) @@ -13,7 +15,6 @@ import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Version (mkVersionRange) import Test.Hspec -import Debug.Trace smpProxyTests :: Spec smpProxyTests = do @@ -39,14 +40,14 @@ proxyVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion testNoProxy :: IO () testNoProxy = do withSmpServerConfigOn (transport @TLS) cfg testPort2 $ \_ -> do - testSMPClient_ "127.0.0.1" testPort2 proxyVRange $ \(th :: THandleSMP TLS) -> do + testSMPClient_ "127.0.0.1" testPort2 proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer Nothing) reply `shouldBe` Right (ERR AUTH) testProxyAuth :: IO () testProxyAuth = do withSmpServerConfigOn (transport @TLS) proxyCfgAuth testPort $ \_ -> do - testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS) -> do + testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, s, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 $ Just "wrong") traceShowM s reply `shouldBe` Right (ERR AUTH) @@ -56,7 +57,7 @@ testProxyAuth = do testProxyConnect :: IO () testProxyConnect = do withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> do - testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS) -> do + testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 Nothing) case reply of Right PKEY {} -> pure () diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index 3b5f2f5dc..a7e8fee86 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -78,13 +78,13 @@ pattern Ids rId sId srvDh <- IDS (QIK rId sId srvDh) pattern Msg :: MsgId -> MsgBody -> BrokerMsg pattern Msg msgId body <- MSG RcvMessage {msgId, msgBody = EncRcvMsgBody body} -sendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c -> (Maybe TransmissionAuth, ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) +sendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) sendRecv h@THandle {params} (sgn, corrId, qId, cmd) = do let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (sgn, tToSend) tGet1 h -signSendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c -> C.APrivateAuthKey -> (ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) +signSendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c 'TClient -> C.APrivateAuthKey -> (ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) signSendRecv h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (authorize tForAuth, tToSend) @@ -93,17 +93,17 @@ signSendRecv h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do authorize t = case a of C.SEd25519 -> Just . TASignature . C.ASignature C.SEd25519 $ C.sign' pk t C.SEd448 -> Just . TASignature . C.ASignature C.SEd448 $ C.sign' pk t - C.SX25519 -> (\THandleAuth {peerPubKey} -> TAAuthenticator $ C.cbAuthenticate peerPubKey pk (C.cbNonce corrId) t) <$> thAuth params + C.SX25519 -> (\THAuthClient {serverPeerPubKey = k} -> TAAuthenticator $ C.cbAuthenticate k pk (C.cbNonce corrId) t) <$> thAuth params #if !MIN_VERSION_base(4,18,0) _sx448 -> undefined -- ghc8107 fails to the branch excluded by types #endif -tPut1 :: Transport c => THandle v c -> SentRawTransmission -> IO (Either TransportError ()) +tPut1 :: Transport c => THandle v c 'TClient -> SentRawTransmission -> IO (Either TransportError ()) tPut1 h t = do [r] <- tPut h [Right t] pure r -tGet1 :: (ProtocolEncoding v err cmd, Transport c) => THandle v c -> IO (SignedTransmission err cmd) +tGet1 :: (ProtocolEncoding v err cmd, Transport c) => THandle v c 'TClient -> IO (SignedTransmission err cmd) tGet1 h = do [r] <- liftIO $ tGet h pure r @@ -555,12 +555,12 @@ testWithStoreLog at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 1 removeFile testStoreLogFile where - runTest :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> ThreadId -> Expectation + runTest :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> ThreadId -> Expectation runTest _ test' server = do testSMPClient test' `shouldReturn` () killThread server - runClient :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> Expectation + runClient :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> Expectation runClient _ test' = testSMPClient test' `shouldReturn` () logSize :: FilePath -> IO Int @@ -653,12 +653,12 @@ testRestoreMessages at@(ATransport t) = removeFile testStoreMsgsFile removeFile testServerStatsBackupFile where - runTest :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> ThreadId -> Expectation + runTest :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> ThreadId -> Expectation runTest _ test' server = do testSMPClient test' `shouldReturn` () killThread server - runClient :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> Expectation + runClient :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> Expectation runClient _ test' = testSMPClient test' `shouldReturn` () checkStats :: ServerStatsData -> [RecipientId] -> Int -> Int -> Expectation @@ -727,15 +727,15 @@ testRestoreExpireMessages at@(ATransport t) = Right ServerStatsData {_msgExpired} <- strDecode <$> B.readFile testServerStatsBackupFile _msgExpired `shouldBe` 2 where - runTest :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> ThreadId -> Expectation + runTest :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> ThreadId -> Expectation runTest _ test' server = do testSMPClient test' `shouldReturn` () killThread server - runClient :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> Expectation + runClient :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> Expectation runClient _ test' = testSMPClient test' `shouldReturn` () -createAndSecureQueue :: Transport c => THandleSMP c -> SndPublicAuthKey -> IO (SenderId, RecipientId, RcvPrivateAuthKey, RcvDhSecret) +createAndSecureQueue :: Transport c => THandleSMP c 'TClient -> SndPublicAuthKey -> IO (SenderId, RecipientId, RcvPrivateAuthKey, RcvDhSecret) createAndSecureQueue h sPub = do g <- C.newRandom (rPub, rKey) <- atomically $ C.generateAuthKeyPair C.SEd448 g @@ -759,8 +759,8 @@ testTiming (ATransport t) = timingTests :: [(C.AuthAlg, C.AuthAlg, Int)] timingTests = [ (C.AuthAlg C.SEd25519, C.AuthAlg C.SEd25519, 200), -- correct key type - -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SEd448, 150), - -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SX25519, 200), + -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SEd448, 150), + -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SX25519, 200), (C.AuthAlg C.SEd448, C.AuthAlg C.SEd25519, 200), (C.AuthAlg C.SEd448, C.AuthAlg C.SEd448, 150), -- correct key type (C.AuthAlg C.SEd448, C.AuthAlg C.SX25519, 200), @@ -770,7 +770,7 @@ testTiming (ATransport t) = ] timeRepeat n = fmap fst . timeItT . forM_ (replicate n ()) . const similarTime t1 t2 = abs (t2 / t1 - 1) < 0.2 -- normally the difference between "no queue" and "wrong key" is less than 5% - testSameTiming :: forall c. Transport c => THandleSMP c -> THandleSMP c -> (C.AuthAlg, C.AuthAlg, Int) -> Expectation + testSameTiming :: forall c. Transport c => THandleSMP c 'TClient -> THandleSMP c 'TClient -> (C.AuthAlg, C.AuthAlg, Int) -> Expectation testSameTiming rh sh (C.AuthAlg goodKeyAlg, C.AuthAlg badKeyAlg, n) = do g <- C.newRandom (rPub, rKey) <- atomically $ C.generateAuthKeyPair goodKeyAlg g @@ -791,10 +791,11 @@ testTiming (ATransport t) = runTimingTest sh badKey sId $ _SEND "hello" where - runTimingTest :: PartyI p => THandleSMP c -> C.APrivateAuthKey -> ByteString -> Command p -> IO () + runTimingTest :: PartyI p => THandleSMP c 'TClient -> C.APrivateAuthKey -> ByteString -> Command p -> IO () runTimingTest h badKey qId cmd = do threadDelay 100000 - _ <- timeRepeat n $ do -- "warm up" the server + _ <- timeRepeat n $ do + -- "warm up" the server Resp "dabc" _ (ERR AUTH) <- signSendRecv h badKey ("dabc", "1234", cmd) return () threadDelay 100000 From 2f43b432254808ecac623d34fb553987d012114c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 15 Apr 2024 13:47:48 +0100 Subject: [PATCH 007/107] parameterize transport handle with transport peer to include server certificate (#1100) * parameterize transport handle with transport peer to include server certificate * include server certificate into THandle * load server chain and sign key * fix key type * fix for 8.10 --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Co-authored-by: IC Rainbow --- src/Simplex/FileTransfer/Client.hs | 13 ++-- src/Simplex/FileTransfer/Protocol.hs | 12 ++-- src/Simplex/FileTransfer/Server.hs | 16 ++--- src/Simplex/FileTransfer/Transport.hs | 9 +-- src/Simplex/Messaging/Client.hs | 14 ++-- src/Simplex/Messaging/Notifications/Server.hs | 10 +-- .../Messaging/Notifications/Server/Env.hs | 8 +-- .../Messaging/Notifications/Transport.hs | 44 +++++++----- src/Simplex/Messaging/Protocol.hs | 22 +++--- src/Simplex/Messaging/Server.hs | 18 ++--- src/Simplex/Messaging/Transport.hs | 70 ++++++++++++------- tests/CoreTests/BatchingTests.hs | 21 ++++-- tests/NtfClient.hs | 8 +-- tests/NtfServerTests.hs | 6 +- tests/SMPClient.hs | 32 ++++----- tests/ServerTests.hs | 35 +++++----- 16 files changed, 191 insertions(+), 147 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index fcb54aece..a223d492e 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Protocol RecipientId, SenderId, ) -import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), supportedParameters) +import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, alpn) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client @@ -64,7 +64,7 @@ import UnliftIO.Directory data XFTPClient = XFTPClient { http2Client :: HTTP2Client, transportSession :: TransportSession FileResponse, - thParams :: THandleParams XFTPVersion, + thParams :: THandleParams XFTPVersion 'TClient, config :: XFTPClientConfig } @@ -120,19 +120,21 @@ getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN atomically $ writeTVar clientVar $ Just c pure c -xftpClientHandshakeV1 :: TVar ChaChaDRG -> VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP -> ExceptT XFTPClientError IO THandleParamsXFTP +xftpClientHandshakeV1 :: TVar ChaChaDRG -> VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP 'TClient -> ExceptT XFTPClientError IO (THandleParamsXFTP 'TClient) xftpClientHandshakeV1 g serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessionId, serverKey} thParams0 = do - shs <- getServerHandshake + shs@XFTPServerHandshake {authPubKey = ck} <- getServerHandshake (v, sk) <- processServerHandshake shs (k, pk) <- atomically $ C.generateKeyPair g sendClientHandshake XFTPClientHandshake {xftpVersion = v, keyHash, authPubKey = k} - pure thParams0 {thAuth = Just THandleAuth {peerPubKey = sk, privKey = pk}, thVersion = v} + pure thParams0 {thAuth = Just THAuthClient {serverPeerPubKey = sk, serverCertKey = ck, clientPrivKey = pk}, thVersion = v} where + getServerHandshake :: ExceptT XFTPClientError IO XFTPServerHandshake getServerHandshake = do let helloReq = H.requestNoBody "POST" "/" [] HTTP2Response {respBody = HTTP2Body {bodyHead = shsBody}} <- liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c helloReq Nothing liftHS . smpDecode =<< liftHS (C.unPad shsBody) + processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionXFTP, C.PublicKeyX25519) processServerHandshake XFTPServerHandshake {xftpVersionRange, sessionId = serverSessId, authPubKey = serverAuth} = do unless (sessionId == serverSessId) $ throwError $ PCEResponseError SESSION case xftpVersionRange `compatibleVersion` serverVRange of @@ -145,6 +147,7 @@ xftpClientHandshakeV1 g serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessi _ -> throwError "bad certificate" pubKey <- maybe (throwError "bad server key type") (`C.verifyX509` exact) serverKey C.x509ToPublic (pubKey, []) >>= C.pubKey + sendClientHandshake :: XFTPClientHandshake -> ExceptT XFTPClientError IO () sendClientHandshake chs = do chs' <- liftHS $ C.pad (smpEncode chs) xftpBlockSize let chsReq = H.requestBuilder "POST" "/" [] $ byteString chs' diff --git a/src/Simplex/FileTransfer/Protocol.hs b/src/Simplex/FileTransfer/Protocol.hs index f970fdfcc..418e48482 100644 --- a/src/Simplex/FileTransfer/Protocol.hs +++ b/src/Simplex/FileTransfer/Protocol.hs @@ -39,8 +39,8 @@ import Simplex.Messaging.Protocol ProtocolErrorType (..), ProtocolMsgTag (..), ProtocolType (..), - RcvPublicDhKey, RcvPublicAuthKey, + RcvPublicDhKey, RecipientId, SenderId, SentRawTransmission, @@ -48,14 +48,14 @@ import Simplex.Messaging.Protocol SndPublicAuthKey, Transmission, TransmissionForAuth (..), - encodeTransmissionForAuth, encodeTransmission, + encodeTransmissionForAuth, messageTagP, tDecodeParseValidate, tEncodeBatch1, tParse, ) -import Simplex.Messaging.Transport (THandleParams (..), TransportError (..)) +import Simplex.Messaging.Transport (THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Util ((<$?>)) xftpBlockSize :: Int @@ -325,12 +325,12 @@ checkParty' c = case testEquality (sFileParty @p) (sFileParty @p') of Just Refl -> Just c _ -> Nothing -xftpEncodeAuthTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion -> C.APrivateAuthKey -> Transmission c -> Either TransportError ByteString +xftpEncodeAuthTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion 'TClient -> C.APrivateAuthKey -> Transmission c -> Either TransportError ByteString xftpEncodeAuthTransmission thParams@THandleParams {thAuth} pKey (corrId, fId, msg) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, fId, msg) xftpEncodeBatch1 . (,tToSend) =<< authTransmission thAuth (Just pKey) corrId tForAuth -xftpEncodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion -> Transmission c -> Either TransportError ByteString +xftpEncodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion p -> Transmission c -> Either TransportError ByteString xftpEncodeTransmission thParams (corrId, fId, msg) = do let t = encodeTransmission thParams (corrId, fId, msg) xftpEncodeBatch1 (Nothing, t) @@ -339,7 +339,7 @@ xftpEncodeTransmission thParams (corrId, fId, msg) = do xftpEncodeBatch1 :: SentRawTransmission -> Either TransportError ByteString xftpEncodeBatch1 t = first (const TELargeMsg) $ C.pad (tEncodeBatch1 t) xftpBlockSize -xftpDecodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion -> ByteString -> Either XFTPErrorType (SignedTransmission e c) +xftpDecodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion p -> ByteString -> Either XFTPErrorType (SignedTransmission e c) xftpDecodeTransmission thParams t = do t' <- first (const BLOCK) $ C.unPad t case tParse thParams t' of diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index b426360ea..b3bee134f 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -56,7 +56,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.Stats import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (SessionId, THandleAuth (..), THandleParams (..)) +import Simplex.Messaging.Transport (SessionId, THandleAuth (..), THandleParams (..), TransportPeer (..)) import Simplex.Messaging.Transport.Buffer (trimCR) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.File (fileBlockSize) @@ -75,7 +75,7 @@ import qualified UnliftIO.Exception as E type M a = ReaderT XFTPEnv IO a data XFTPTransportRequest = XFTPTransportRequest - { thParams :: THandleParamsXFTP, + { thParams :: THandleParamsXFTP 'TServer, reqBody :: HTTP2Body, request :: H.Request, sendResponse :: H.Response -> IO () @@ -91,7 +91,7 @@ runXFTPServerBlocking started cfg = newXFTPServerEnv cfg >>= runReaderT (xftpSer data Handshake = HandshakeSent C.PrivateKeyX25519 - | HandshakeAccepted THandleAuth VersionXFTP + | HandshakeAccepted (THandleAuth 'TServer) VersionXFTP xftpServer :: XFTPServerConfig -> TMVar Bool -> M () xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration} started = do @@ -120,7 +120,7 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira Nothing -> pure () -- handshake response sent Just thParams -> processRequest req0 {thParams} -- proceed with new version (XXX: may as well switch the request handler here) _ -> liftIO . sendResponse $ H.responseNoBody N.ok200 [] -- shouldn't happen: means server picked handshake protocol it doesn't know about - xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion)) + xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion 'TServer)) xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse} = do s <- atomically $ TM.lookup sessionId sessions r <- runExceptT $ case s of @@ -138,18 +138,18 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira shs <- encodeXftp hs liftIO . sendResponse $ H.responseBuilder N.ok200 [] shs pure Nothing - processClientHandshake privKey = do + processClientHandshake pk = do unless (B.length bodyHead == xftpBlockSize) $ throwError HANDSHAKE body <- liftHS $ C.unPad bodyHead XFTPClientHandshake {xftpVersion, keyHash, authPubKey} <- liftHS $ smpDecode body kh <- asks serverIdentity unless (keyHash == kh) $ throwError HANDSHAKE unless (xftpVersion `isCompatible` supportedFileServerVRange) $ throwError HANDSHAKE - let auth = THandleAuth {peerPubKey = authPubKey, privKey} + let auth = THAuthServer {clientPeerPubKey = authPubKey, serverPrivKey = pk} atomically $ TM.insert sessionId (HandshakeAccepted auth xftpVersion) sessions liftIO . sendResponse $ H.responseNoBody N.ok200 [] pure Nothing - sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion)) + sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion 'TServer)) sendError err = do runExceptT (encodeXftp err) >>= \case Right bs -> liftIO . sendResponse $ H.responseBuilder N.ok200 [] bs @@ -326,7 +326,7 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea data VerificationResult = VRVerified XFTPRequest | VRFailed -verifyXFTPTransmission :: Maybe (THandleAuth, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> XFTPFileId -> FileCmd -> M VerificationResult +verifyXFTPTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> XFTPFileId -> FileCmd -> M VerificationResult verifyXFTPTransmission auth_ tAuth authorized fId cmd = case cmd of FileCmd SFSender (FNEW file rcps auth') -> pure $ XFTPReqNew file rcps auth' `verifyWith` sndKey file diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index d8bb72f6b..041069e98 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} @@ -51,7 +52,7 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol (CommandError) -import Simplex.Messaging.Transport (HandshakeError (..), SessionId, THandle (..), THandleParams (..), TransportError (..)) +import Simplex.Messaging.Transport (HandshakeError (..), SessionId, THandle (..), THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.File import Simplex.Messaging.Util (bshow) import Simplex.Messaging.Version @@ -76,8 +77,8 @@ type VersionRangeXFTP = VersionRange XFTPVersion pattern VersionXFTP :: Word16 -> VersionXFTP pattern VersionXFTP v = Version v -type THandleXFTP c = THandle XFTPVersion c -type THandleParamsXFTP = THandleParams XFTPVersion +type THandleXFTP c p = THandle XFTPVersion c p +type THandleParamsXFTP p = THandleParams XFTPVersion p initialXFTPVersion :: VersionXFTP initialXFTPVersion = VersionXFTP 1 @@ -89,7 +90,7 @@ supportedFileServerVRange :: VersionRangeXFTP supportedFileServerVRange = mkVersionRange initialXFTPVersion currentXFTPVersion -- XFTP protocol does not support handshake -xftpClientHandshakeStub :: c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c) +xftpClientHandshakeStub :: c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c 'TClient) xftpClientHandshakeStub _c _ks _keyHash _xftpVRange = throwError $ TEHandshake VERSION data XFTPServerHandshake = XFTPServerHandshake diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index e0591b14d..61ec96003 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -84,8 +84,8 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception import Control.Monad -import Control.Monad.IO.Class (liftIO) import Control.Monad.Except +import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as J @@ -119,7 +119,7 @@ import System.Timeout (timeout) -- Use 'getSMPClient' to connect to an SMP server and create a client handle. data ProtocolClient v err msg = ProtocolClient { action :: Maybe (Async ()), - thParams :: THandleParams v, + thParams :: THandleParams v 'TClient, sessionTs :: UTCTime, client_ :: PClient v err msg } @@ -138,7 +138,7 @@ data PClient v err msg = PClient msgQ :: Maybe (TBQueue (ServerTransmission v msg)) } -smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe THandleAuth -> STM SMPClient +smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe (THandleAuth 'TClient) -> STM SMPClient smpClientStub g sessionId thVersion thAuth = do connected <- newTVar False clientCorrId <- C.newRandomDRG g @@ -387,10 +387,10 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize raceAny_ ([send c' th, process c', receive c' th] <> [ping c' | smpPingInterval > 0]) `finally` disconnected c' - send :: Transport c => ProtocolClient v err msg -> THandle v c -> IO () + send :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= tPutLog h - receive :: Transport c => ProtocolClient v err msg -> THandle v c -> IO () + receive :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () receive ProtocolClient {client_ = PClient {rcvQ}} h = forever $ tGet h >>= atomically . writeTBQueue rcvQ ping :: ProtocolClient v err msg -> IO () @@ -733,13 +733,13 @@ mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCo TM.insert corrId r sentCommands pure r -authTransmission :: Maybe THandleAuth -> Maybe C.APrivateAuthKey -> CorrId -> ByteString -> Either TransportError (Maybe TransmissionAuth) +authTransmission :: Maybe (THandleAuth 'TClient) -> Maybe C.APrivateAuthKey -> CorrId -> ByteString -> Either TransportError (Maybe TransmissionAuth) authTransmission thAuth pKey_ (CorrId corrId) t = traverse authenticate pKey_ where authenticate :: C.APrivateAuthKey -> Either TransportError TransmissionAuth authenticate (C.APrivateAuthKey a pk) = case a of C.SX25519 -> case thAuth of - Just THandleAuth {peerPubKey} -> Right $ TAAuthenticator $ C.cbAuthenticate peerPubKey pk (C.cbNonce corrId) t + Just THAuthClient {serverPeerPubKey = k} -> Right $ TAAuthenticator $ C.cbAuthenticate k pk (C.cbNonce corrId) t Nothing -> Left TENoServerAuth C.SEd25519 -> sign pk C.SEd448 -> sign pk diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index 55ab40718..55bdb07eb 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -47,7 +47,7 @@ import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Server import Simplex.Messaging.Server.Stats import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport (..), THandle (..), THandleAuth (..), THandleParams (..), TProxy, Transport (..)) +import Simplex.Messaging.Transport (ATransport (..), THandle (..), THandleAuth (..), THandleParams (..), TProxy, Transport (..), TransportPeer (..)) import Simplex.Messaging.Transport.Server (runTransportServer, tlsServerCredentials) import Simplex.Messaging.Util import System.Exit (exitFailure) @@ -339,7 +339,7 @@ updateTknStatus NtfTknData {ntfTknId, tknStatus} status = do old <- atomically $ stateTVar tknStatus (,status) when (old /= status) $ withNtfLog $ \sl -> logTokenStatus sl ntfTknId status -runNtfClientTransport :: Transport c => THandleNTF c -> M () +runNtfClientTransport :: Transport c => THandleNTF c 'TServer -> M () runNtfClientTransport th@THandle {params} = do qSize <- asks $ clientQSize . config ts <- liftIO getSystemTime @@ -356,7 +356,7 @@ runNtfClientTransport th@THandle {params} = do clientDisconnected :: NtfServerClient -> IO () clientDisconnected NtfServerClient {connected} = atomically $ writeTVar connected False -receive :: Transport c => THandleNTF c -> NtfServerClient -> M () +receive :: Transport c => THandleNTF c 'TServer -> NtfServerClient -> M () receive th@THandle {params = THandleParams {thAuth}} NtfServerClient {rcvQ, sndQ, rcvActiveAt} = forever $ do ts <- liftIO $ tGet th forM_ ts $ \t@(_, _, (corrId, entId, cmdOrError)) -> do @@ -371,7 +371,7 @@ receive th@THandle {params = THandleParams {thAuth}} NtfServerClient {rcvQ, sndQ where write q t = atomically $ writeTBQueue q t -send :: Transport c => THandleNTF c -> NtfServerClient -> IO () +send :: Transport c => THandleNTF c 'TServer -> NtfServerClient -> IO () send h@THandle {params} NtfServerClient {sndQ, sndActiveAt} = forever $ do t <- atomically $ readTBQueue sndQ void . liftIO $ tPut h [Right (Nothing, encodeTransmission params t)] @@ -382,7 +382,7 @@ send h@THandle {params} NtfServerClient {sndQ, sndActiveAt} = forever $ do data VerificationResult = VRVerified NtfRequest | VRFailed -verifyNtfTransmission :: Maybe (THandleAuth, C.CbNonce) -> SignedTransmission ErrorType NtfCmd -> NtfCmd -> M VerificationResult +verifyNtfTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> SignedTransmission ErrorType NtfCmd -> NtfCmd -> M VerificationResult verifyNtfTransmission auth_ (tAuth, authorized, (corrId, entId, _)) cmd = do st <- asks store case cmd of diff --git a/src/Simplex/Messaging/Notifications/Server/Env.hs b/src/Simplex/Messaging/Notifications/Server/Env.hs index 4a93a8a34..5bcd72f3d 100644 --- a/src/Simplex/Messaging/Notifications/Server/Env.hs +++ b/src/Simplex/Messaging/Notifications/Server/Env.hs @@ -24,16 +24,16 @@ import Numeric.Natural import Simplex.Messaging.Client.Agent import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol -import Simplex.Messaging.Notifications.Transport (NTFVersion, VersionRangeNTF) import Simplex.Messaging.Notifications.Server.Push.APNS import Simplex.Messaging.Notifications.Server.Stats import Simplex.Messaging.Notifications.Server.Store import Simplex.Messaging.Notifications.Server.StoreLog +import Simplex.Messaging.Notifications.Transport (NTFVersion, VersionRangeNTF) import Simplex.Messaging.Protocol (CorrId, SMPServer, Transmission) import Simplex.Messaging.Server.Expiration import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport, THandleParams) +import Simplex.Messaging.Transport (ATransport, THandleParams, TransportPeer (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig, loadFingerprint, loadTLSServerParams) import System.IO (IOMode (..)) import System.Mem.Weak (Weak) @@ -161,13 +161,13 @@ data NtfRequest data NtfServerClient = NtfServerClient { rcvQ :: TBQueue NtfRequest, sndQ :: TBQueue (Transmission NtfResponse), - ntfThParams :: THandleParams NTFVersion, + ntfThParams :: THandleParams NTFVersion 'TServer, connected :: TVar Bool, rcvActiveAt :: TVar SystemTime, sndActiveAt :: TVar SystemTime } -newNtfServerClient :: Natural -> THandleParams NTFVersion -> SystemTime -> STM NtfServerClient +newNtfServerClient :: Natural -> THandleParams NTFVersion 'TServer -> SystemTime -> STM NtfServerClient newNtfServerClient qSize ntfThParams ts = do rcvQ <- newTBQueue qSize sndQ <- newTBQueue qSize diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index bc68fab03..d4a4a4cbe 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -5,6 +5,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} module Simplex.Messaging.Notifications.Transport where @@ -18,9 +19,9 @@ import qualified Data.X509 as X import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Transport +import Simplex.Messaging.Util (liftEitherWith) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal -import Simplex.Messaging.Util (liftEitherWith) ntfBlockSize :: Int ntfBlockSize = 512 @@ -54,7 +55,7 @@ supportedClientNTFVRange = mkVersionRange initialNTFVersion currentClientNTFVers supportedServerNTFVRange :: VersionRangeNTF supportedServerNTFVRange = mkVersionRange initialNTFVersion currentServerNTFVersion -type THandleNTF c = THandle NTFVersion c +type THandleNTF c p = THandle NTFVersion c p data NtfServerHandshake = NtfServerHandshake { ntfVersionRange :: VersionRangeNTF, @@ -111,7 +112,7 @@ encodeNtfAuthPubKey v k | otherwise = "" -- | Notifcations server transport handshake. -ntfServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c) +ntfServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TServer) ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do let th@THandle {params = THandleParams {sessionId}} = ntfTHandle c let sk = C.signX509 serverSignKey $ C.publicToX509 k @@ -121,11 +122,11 @@ ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do | keyHash /= kh -> throwError $ TEHandshake IDENTITY | v `isCompatible` ntfVRange -> - pure $ ntfThHandle th v pk k' + pure $ ntfThHandleServer th v pk k' | otherwise -> throwError $ TEHandshake VERSION -- | Notifcations server client transport handshake. -ntfClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c) +ntfClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TClient) ntfClientHandshake c (k, pk) keyHash ntfVRange = do let th@THandle {params = THandleParams {sessionId}} = ntfTHandle c NtfServerHandshake {sessionId = sessId, ntfVersionRange, authPubKey = sk'} <- getHandshake th @@ -133,23 +134,32 @@ ntfClientHandshake c (k, pk) keyHash ntfVRange = do then throwError TEBadSession else case ntfVersionRange `compatibleVersion` ntfVRange of Just (Compatible v) -> do - sk_ <- forM sk' $ \exact -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do + ck_ <- forM sk' $ \signedKey -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do serverKey <- getServerVerifyKey c - pubKey <- C.verifyX509 serverKey exact - C.x509ToPublic (pubKey, []) >>= C.pubKey + pubKey <- C.verifyX509 serverKey signedKey + (,(getServerCerts c, signedKey)) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) sendHandshake th $ NtfClientHandshake {ntfVersion = v, keyHash, authPubKey = Just k} - pure $ ntfThHandle th v pk sk_ + pure $ ntfThHandleClient th v pk ck_ Nothing -> throwError $ TEHandshake VERSION -ntfThHandle :: forall c. THandleNTF c -> VersionNTF -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleNTF c -ntfThHandle th@THandle {params} v privKey k_ = - -- TODO drop SMP v6: make thAuth non-optional - let thAuth = (\k -> THandleAuth {peerPubKey = k, privKey}) <$> k_ - v3 = v >= authBatchCmdsNTFVersion - params' = params {thVersion = v, thAuth, implySessId = v3, batch = v3} - in (th :: THandleNTF c) {params = params'} +ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleNTF c 'TServer +ntfThHandleServer th v pk k_ = + let thAuth = (\k -> THAuthServer {clientPeerPubKey = k, serverPrivKey = pk}) <$> k_ + in ntfThHandle_ th v thAuth -ntfTHandle :: Transport c => c -> THandleNTF c +ntfThHandleClient :: forall c. THandleNTF c 'TClient -> VersionNTF -> C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleNTF c 'TClient +ntfThHandleClient th v pk ck_ = + let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, clientPrivKey = pk}) <$> ck_ + in ntfThHandle_ th v thAuth + +ntfThHandle_ :: forall c p. THandleNTF c p -> VersionNTF -> Maybe (THandleAuth p) -> THandleNTF c p +ntfThHandle_ th@THandle {params} v thAuth = + -- TODO drop SMP v6: make thAuth non-optional + let v3 = v >= authBatchCmdsNTFVersion + params' = params {thVersion = v, thAuth, implySessId = v3, batch = v3} + in (th :: THandleNTF c p) {params = params'} + +ntfTHandle :: Transport c => c -> THandleNTF c p ntfTHandle c = THandle {connection = c, params} where params = THandleParams {sessionId = tlsUnique c, blockSize = ntfBlockSize, thVersion = VersionNTF 0, thAuth = Nothing, implySessId = False, batch = False} diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 2c593fc6f..31577ccdb 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1075,7 +1075,7 @@ data CommandError deriving (Eq, Read, Show) -- | SMP transmission parser. -transmissionP :: THandleParams v -> Parser RawTransmission +transmissionP :: THandleParams v p -> Parser RawTransmission transmissionP THandleParams {sessionId, implySessId} = do authenticator <- smpP authorized <- A.takeByteString @@ -1089,10 +1089,10 @@ transmissionP THandleParams {sessionId, implySessId} = do command <- A.takeByteString pure RawTransmission {authenticator, authorized = authorized', sessId, corrId, entityId, command} -class (ProtocolEncoding v err msg, ProtocolEncoding v err (ProtoCommand msg), Show err, Show msg) => Protocol v err msg | msg -> v, msg -> err where +class (ProtocolEncoding v err msg, ProtocolEncoding v err (ProtoCommand msg), Show err, Show msg) => Protocol v err msg | msg -> v, msg -> err where type ProtoCommand msg = cmd | cmd -> msg type ProtoType msg = (sch :: ProtocolType) | sch -> msg - protocolClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRange v -> ExceptT TransportError IO (THandle v c) + protocolClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRange v -> ExceptT TransportError IO (THandle v c 'TClient) protocolPing :: ProtoCommand msg protocolError :: msg -> Maybe err @@ -1308,7 +1308,7 @@ instance Encoding CommandError where _ -> fail "bad command error type" -- | Send signed SMP transmission to TCP transport. -tPut :: Transport c => THandle v c -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] +tPut :: Transport c => THandle v c p -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] tPut th@THandle {params} = fmap concat . mapM tPutBatch . batchTransmissions (batch params) (blockSize params) where tPutBatch :: TransportBatch () -> IO [Either TransportError ()] @@ -1317,7 +1317,7 @@ tPut th@THandle {params} = fmap concat . mapM tPutBatch . batchTransmissions (ba TBTransmissions s n _ -> replicate n <$> tPutLog th s TBTransmission s _ -> (: []) <$> tPutLog th s -tPutLog :: Transport c => THandle v c -> ByteString -> IO (Either TransportError ()) +tPutLog :: Transport c => THandle v c p -> ByteString -> IO (Either TransportError ()) tPutLog th s = do r <- tPutBlock th s case r of @@ -1383,7 +1383,7 @@ tEncodeBatch1 t = lenEncode 1 `B.cons` tEncodeForBatch t -- tForAuth is lazy to avoid computing it when there is no key to sign data TransmissionForAuth = TransmissionForAuth {tForAuth :: ~ByteString, tToSend :: ByteString} -encodeTransmissionForAuth :: ProtocolEncoding v e c => THandleParams v -> Transmission c -> TransmissionForAuth +encodeTransmissionForAuth :: ProtocolEncoding v e c => THandleParams v p -> Transmission c -> TransmissionForAuth encodeTransmissionForAuth THandleParams {thVersion = v, sessionId, implySessId} t = TransmissionForAuth {tForAuth, tToSend = if implySessId then t' else tForAuth} where @@ -1391,7 +1391,7 @@ encodeTransmissionForAuth THandleParams {thVersion = v, sessionId, implySessId} t' = encodeTransmission_ v t {-# INLINE encodeTransmissionForAuth #-} -encodeTransmission :: ProtocolEncoding v e c => THandleParams v -> Transmission c -> ByteString +encodeTransmission :: ProtocolEncoding v e c => THandleParams v p -> Transmission c -> ByteString encodeTransmission THandleParams {thVersion = v, sessionId, implySessId} t = if implySessId then t' else smpEncode sessionId <> t' where @@ -1404,11 +1404,11 @@ encodeTransmission_ v (CorrId corrId, queueId, command) = {-# INLINE encodeTransmission_ #-} -- | Receive and parse transmission from the TCP transport (ignoring any trailing padding). -tGetParse :: Transport c => THandle v c -> IO (NonEmpty (Either TransportError RawTransmission)) +tGetParse :: Transport c => THandle v c p -> IO (NonEmpty (Either TransportError RawTransmission)) tGetParse th@THandle {params} = eitherList (tParse params) <$> tGetBlock th {-# INLINE tGetParse #-} -tParse :: THandleParams v -> ByteString -> NonEmpty (Either TransportError RawTransmission) +tParse :: THandleParams v p -> ByteString -> NonEmpty (Either TransportError RawTransmission) tParse thParams@THandleParams {batch} s | batch = eitherList (L.map (\(Large t) -> tParse1 t)) ts | otherwise = [tParse1 s] @@ -1420,10 +1420,10 @@ eitherList :: (a -> NonEmpty (Either e b)) -> Either e a -> NonEmpty (Either e b eitherList = either (\e -> [Left e]) -- | Receive client and server transmissions (determined by `cmd` type). -tGet :: forall v err cmd c. (ProtocolEncoding v err cmd, Transport c) => THandle v c -> IO (NonEmpty (SignedTransmission err cmd)) +tGet :: forall v err cmd c p. (ProtocolEncoding v err cmd, Transport c) => THandle v c p -> IO (NonEmpty (SignedTransmission err cmd)) tGet th@THandle {params} = L.map (tDecodeParseValidate params) <$> tGetParse th -tDecodeParseValidate :: forall v err cmd. ProtocolEncoding v err cmd => THandleParams v -> Either TransportError RawTransmission -> SignedTransmission err cmd +tDecodeParseValidate :: forall v p err cmd. ProtocolEncoding v err cmd => THandleParams v p -> Either TransportError RawTransmission -> SignedTransmission err cmd tDecodeParseValidate THandleParams {sessionId, thVersion = v, implySessId} = \case Right RawTransmission {authenticator, authorized, sessId, corrId, entityId, command} | implySessId || sessId == sessionId -> diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 4535c7bd5..5415ebd65 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -409,7 +409,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do logError "Unauthorized control port command" hPutStrLn h "AUTH" -runClientTransport :: Transport c => THandleSMP c -> M () +runClientTransport :: Transport c => THandleSMP c 'TServer -> M () runClientTransport th@THandle {params = THandleParams {thVersion, sessionId}} = do q <- asks $ tbqSize . config ts <- liftIO getSystemTime @@ -457,7 +457,7 @@ cancelSub sub = Sub {subThread = SubThread t} -> liftIO $ deRefWeak t >>= mapM_ killThread _ -> return () -receive :: Transport c => THandleSMP c -> Client -> M () +receive :: Transport c => THandleSMP c 'TServer -> Client -> M () receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActiveAt, sessionId} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " receive" forever $ do @@ -478,7 +478,7 @@ receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActi VRFailed -> Left (corrId, queueId, ERR AUTH) write q = mapM_ (atomically . writeTBQueue q) . L.nonEmpty -send :: Transport c => THandleSMP c -> Client -> IO () +send :: Transport c => THandleSMP c 'TServer -> Client -> IO () send h@THandle {params} Client {sndQ, sessionId, sndActiveAt} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " send" forever $ do @@ -493,7 +493,7 @@ send h@THandle {params} Client {sndQ, sessionId, sndActiveAt} = do NMSG {} -> 0 _ -> 1 -disconnectTransport :: Transport c => THandle v c -> TVar SystemTime -> TVar SystemTime -> ExpirationConfig -> IO Bool -> IO () +disconnectTransport :: Transport c => THandle v c 'TServer -> TVar SystemTime -> TVar SystemTime -> ExpirationConfig -> IO Bool -> IO () disconnectTransport THandle {connection, params = THandleParams {sessionId}} rcvActiveAt sndActiveAt expCfg noSubscriptions = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " disconnectTransport" loop @@ -514,7 +514,7 @@ data VerificationResult = VRVerified (Maybe QueueRec) | VRFailed -- - the queue or party key do not exist. -- In all cases, the time of the verification should depend only on the provided authorization type, -- a dummy key is used to run verification in the last two cases, and failure is returned irrespective of the result. -verifyTransmission :: Maybe (THandleAuth, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> QueueId -> Cmd -> M VerificationResult +verifyTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> QueueId -> Cmd -> M VerificationResult verifyTransmission auth_ tAuth authorized queueId cmd = case cmd of Cmd SRecipient (NEW k _ _ _) -> pure $ Nothing `verifiedWith` k @@ -536,7 +536,7 @@ verifyTransmission auth_ tAuth authorized queueId cmd = st <- asks queueStore atomically $ getQueue st party queueId -verifyCmdAuthorization :: Maybe (THandleAuth, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> C.APublicAuthKey -> Bool +verifyCmdAuthorization :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> C.APublicAuthKey -> Bool verifyCmdAuthorization auth_ tAuth authorized key = maybe False (verify key) tAuth where verify :: C.APublicAuthKey -> TransmissionAuth -> Bool @@ -548,12 +548,12 @@ verifyCmdAuthorization auth_ tAuth authorized key = maybe False (verify key) tAu C.SX25519 -> verifyCmdAuth auth_ k s authorized _ -> verifyCmdAuth auth_ dummyKeyX25519 s authorized `seq` False -verifyCmdAuth :: Maybe (THandleAuth, C.CbNonce) -> C.PublicKeyX25519 -> C.CbAuthenticator -> ByteString -> Bool +verifyCmdAuth :: Maybe (THandleAuth 'TServer, C.CbNonce) -> C.PublicKeyX25519 -> C.CbAuthenticator -> ByteString -> Bool verifyCmdAuth auth_ k authenticator authorized = case auth_ of - Just (THandleAuth {privKey}, nonce) -> C.cbVerify k privKey nonce authenticator authorized + Just (THAuthServer {serverPrivKey = pk}, nonce) -> C.cbVerify k pk nonce authenticator authorized Nothing -> False -dummyVerifyCmd :: Maybe (THandleAuth, C.CbNonce) -> ByteString -> TransmissionAuth -> Bool +dummyVerifyCmd :: Maybe (THandleAuth 'TServer, C.CbNonce) -> ByteString -> TransmissionAuth -> Bool dummyVerifyCmd auth_ authorized = \case TASignature (C.ASignature a s) -> C.verify' (dummySignKey a) s authorized TAAuthenticator s -> verifyCmdAuth auth_ dummyKeyX25519 s authorized diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 6898af15d..6d4ccc83b 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -12,6 +12,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} -- | @@ -311,20 +312,20 @@ instance Transport TLS where -- * SMP transport -- | The handle for SMP encrypted transport connection over Transport. -data THandle v c = THandle +data THandle v c p = THandle { connection :: c, - params :: THandleParams v + params :: THandleParams v p } -type THandleSMP c = THandle SMPVersion c +type THandleSMP c p = THandle SMPVersion c p -data THandleParams v = THandleParams +data THandleParams v p = THandleParams { sessionId :: SessionId, blockSize :: Int, -- | agreed server protocol version thVersion :: Version v, -- | peer public key for command authorization and shared secrets for entity ID encryption - thAuth :: Maybe THandleAuth, + thAuth :: Maybe (THandleAuth p), -- | do NOT send session ID in transmission, but include it into signed message -- based on protocol version implySessId :: Bool, @@ -333,10 +334,18 @@ data THandleParams v = THandleParams batch :: Bool } -data THandleAuth = THandleAuth - { peerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key - privKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) - } +data THandleAuth (p :: TransportPeer) where + THAuthClient :: + { serverPeerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key + serverCertKey :: (X.CertificateChain, X.SignedExact X.PubKey), -- the key here is clientPrivKey signed with server certificate + clientPrivKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) + } -> + THandleAuth 'TClient + THAuthServer :: + { clientPeerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key + serverPrivKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) + } -> + THandleAuth 'TServer -- | TLS-unique channel binding type SessionId = ByteString @@ -438,13 +447,13 @@ serializeTransportError = \case TEHandshake e -> "HANDSHAKE " <> bshow e -- | Pad and send block to SMP transport. -tPutBlock :: Transport c => THandle v c -> ByteString -> IO (Either TransportError ()) +tPutBlock :: Transport c => THandle v c p -> ByteString -> IO (Either TransportError ()) tPutBlock THandle {connection = c, params = THandleParams {blockSize}} block = bimapM (const $ pure TELargeMsg) (cPut c) $ C.pad block blockSize -- | Receive block from SMP transport. -tGetBlock :: Transport c => THandle v c -> IO (Either TransportError ByteString) +tGetBlock :: Transport c => THandle v c p -> IO (Either TransportError ByteString) tGetBlock THandle {connection = c, params = THandleParams {blockSize}} = do msg <- cGet c blockSize if B.length msg == blockSize @@ -454,7 +463,7 @@ tGetBlock THandle {connection = c, params = THandleParams {blockSize}} = do -- | Server SMP transport handshake. -- -- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a -smpServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c) +smpServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c 'TServer) smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do let th@THandle {params = THandleParams {sessionId}} = smpTHandle c sk = C.signX509 serverSignKey $ C.publicToX509 k @@ -465,13 +474,13 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do | keyHash /= kh -> throwE $ TEHandshake IDENTITY | v `isCompatible` smpVRange -> - pure $ smpThHandle th v pk k' + pure $ smpThHandleServer th v pk k' | otherwise -> throwE $ TEHandshake VERSION -- | Client SMP transport handshake. -- -- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a -smpClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c) +smpClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c 'TClient) smpClientHandshake c (k, pk) keyHash@(C.KeyHash kh) smpVRange = do let th@THandle {params = THandleParams {sessionId}} = smpTHandle c ServerHandshake {sessionId = sessId, smpVersionRange, authPubKey} <- getHandshake th @@ -479,33 +488,42 @@ smpClientHandshake c (k, pk) keyHash@(C.KeyHash kh) smpVRange = do then throwE TEBadSession else case smpVersionRange `compatibleVersion` smpVRange of Just (Compatible v) -> do - sk_ <- forM authPubKey $ \(X.CertificateChain cert, exact) -> + ck_ <- forM authPubKey $ \certKey@(X.CertificateChain cert, exact) -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do case cert of [_leaf, ca] | XV.Fingerprint kh == XV.getFingerprint ca X.HashSHA256 -> pure () _ -> throwError "bad certificate" serverKey <- getServerVerifyKey c pubKey <- C.verifyX509 serverKey exact - C.x509ToPublic (pubKey, []) >>= C.pubKey + (,certKey) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = Just k} - pure $ smpThHandle th v pk sk_ + pure $ smpThHandleClient th v pk ck_ Nothing -> throwE $ TEHandshake VERSION -smpThHandle :: forall c. THandleSMP c -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c -smpThHandle th@THandle {params} v privKey k_ = - -- TODO drop SMP v6: make thAuth non-optional - let thAuth = (\k -> THandleAuth {peerPubKey = k, privKey}) <$> k_ - params' = params {thVersion = v, thAuth, implySessId = v >= authCmdsSMPVersion} - in (th :: THandleSMP c) {params = params'} +smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer +smpThHandleServer th v pk k_ = + let thAuth = (\k -> THAuthServer {clientPeerPubKey = k, serverPrivKey = pk}) <$> k_ + in smpThHandle_ th v thAuth -sendHandshake :: (Transport c, Encoding smp) => THandle v c -> smp -> ExceptT TransportError IO () +smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient +smpThHandleClient th v pk ck_ = + let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, clientPrivKey = pk}) <$> ck_ + in smpThHandle_ th v thAuth + +smpThHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> Maybe (THandleAuth p) -> THandleSMP c p +smpThHandle_ th@THandle {params} v thAuth = + -- TODO drop SMP v6: make thAuth non-optional + let params' = params {thVersion = v, thAuth, implySessId = v >= authCmdsSMPVersion} + in (th :: THandleSMP c p) {params = params'} + +sendHandshake :: (Transport c, Encoding smp) => THandle v c p -> smp -> ExceptT TransportError IO () sendHandshake th = ExceptT . tPutBlock th . smpEncode -- ignores tail bytes to allow future extensions -getHandshake :: (Transport c, Encoding smp) => THandle v c -> ExceptT TransportError IO smp +getHandshake :: (Transport c, Encoding smp) => THandle v c p -> ExceptT TransportError IO smp getHandshake th = ExceptT $ (first (\_ -> TEHandshake PARSE) . A.parseOnly smpP =<<) <$> tGetBlock th -smpTHandle :: Transport c => c -> THandleSMP c +smpTHandle :: Transport c => c -> THandleSMP c p smpTHandle c = THandle {connection = c, params} where params = THandleParams {sessionId = tlsUnique c, blockSize = smpBlockSize, thVersion = VersionSMP 0, thAuth = Nothing, implySessId = False, batch = True} diff --git a/tests/CoreTests/BatchingTests.hs b/tests/CoreTests/BatchingTests.hs index 996d2fed1..c349096dc 100644 --- a/tests/CoreTests/BatchingTests.hs +++ b/tests/CoreTests/BatchingTests.hs @@ -1,7 +1,9 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} module CoreTests.BatchingTests (batchingTests) where @@ -11,6 +13,9 @@ import Crypto.Random (ChaChaDRG) import qualified Data.ByteString as B import Data.ByteString.Char8 (ByteString) import qualified Data.List.NonEmpty as L +import qualified Data.X509 as X +import qualified Data.X509.CertificateStore as XS +import qualified Data.X509.File as XF import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol @@ -314,7 +319,7 @@ randomSEND_ a v sessId len = do TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, sId, Cmd SSender $ SEND noMsgFlags msg) pure $ (,tToSend) <$> authTransmission thAuth_ (Just spKey) corrId tForAuth -testTHandleParams :: VersionSMP -> ByteString -> THandleParams SMPVersion +testTHandleParams :: VersionSMP -> ByteString -> THandleParams SMPVersion 'TClient testTHandleParams v sessionId = THandleParams { sessionId, @@ -325,11 +330,17 @@ testTHandleParams v sessionId = batch = True } -testTHandleAuth :: VersionSMP -> TVar ChaChaDRG -> C.APublicAuthKey -> IO (Maybe THandleAuth) -testTHandleAuth v g (C.APublicAuthKey a k) = case a of +testTHandleAuth :: VersionSMP -> TVar ChaChaDRG -> C.APublicAuthKey -> IO (Maybe (THandleAuth 'TClient)) +testTHandleAuth v g (C.APublicAuthKey a serverPeerPubKey) = case a of C.SX25519 | v >= authCmdsSMPVersion -> do - (_, privKey) <- atomically $ C.generateKeyPair g - pure $ Just THandleAuth {peerPubKey = k, privKey} + (_, clientPrivKey) <- atomically $ C.generateKeyPair @'C.X25519 g + ca <- head <$> XS.readCertificates "tests/fixtures/ca.crt" + serverCert <- head <$> XS.readCertificates "tests/fixtures/server.crt" + serverKey <- head <$> XF.readKeyFile "tests/fixtures/server.key" + signKey <- either error pure $ C.x509ToPrivate (serverKey, []) >>= C.privKey @C.APrivateSignKey + (serverAuthPub, _) <- atomically $ C.generateKeyPair @'C.X25519 g + let serverCertKey = (X.CertificateChain [serverCert, ca], C.signX509 signKey $ C.toPubKey C.publicToX509 serverAuthPub) + pure $ Just THAuthClient {serverPeerPubKey, serverCertKey, clientPrivKey} _ -> pure Nothing randomSENDCmd :: ProtocolClient SMPVersion ErrorType BrokerMsg -> Int -> IO (PCTransmission ErrorType BrokerMsg) diff --git a/tests/NtfClient.hs b/tests/NtfClient.hs index 26981628f..d47722944 100644 --- a/tests/NtfClient.hs +++ b/tests/NtfClient.hs @@ -70,7 +70,7 @@ testKeyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=" ntfTestStoreLogFile :: FilePath ntfTestStoreLogFile = "tests/tmp/ntf-server-store.log" -testNtfClient :: Transport c => (THandleNTF c -> IO a) -> IO a +testNtfClient :: Transport c => (THandleNTF c 'TClient -> IO a) -> IO a testNtfClient client = do Right host <- pure $ chooseTransportHost defaultNetworkConfig testHost runTransportClient defaultTransportClientConfig Nothing host ntfTestPort (Just testKeyHash) $ \h -> do @@ -139,7 +139,7 @@ withNtfServerOn t port' = withNtfServerThreadOn t port' . const withNtfServer :: ATransport -> IO a -> IO a withNtfServer t = withNtfServerOn t ntfTestPort -runNtfTest :: forall c a. Transport c => (THandleNTF c -> IO a) -> IO a +runNtfTest :: forall c a. Transport c => (THandleNTF c 'TClient -> IO a) -> IO a runNtfTest test = withNtfServer (transport @c) $ testNtfClient test ntfServerTest :: @@ -150,7 +150,7 @@ ntfServerTest :: IO (Maybe TransmissionAuth, ByteString, ByteString, NtfResponse) ntfServerTest _ t = runNtfTest $ \h -> tPut' h t >> tGet' h where - tPut' :: THandleNTF c -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () + tPut' :: THandleNTF c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () tPut' h@THandle {params = THandleParams {sessionId, implySessId}} (sig, corrId, queueId, smp) = do let t' = if implySessId then smpEncode (corrId, queueId, smp) else smpEncode (sessionId, corrId, queueId, smp) [Right ()] <- tPut h [Right (sig, t')] @@ -159,7 +159,7 @@ ntfServerTest _ t = runNtfTest $ \h -> tPut' h t >> tGet' h [(Nothing, _, (CorrId corrId, qId, Right cmd))] <- tGet h pure (Nothing, corrId, qId, cmd) -ntfTest :: Transport c => TProxy c -> (THandleNTF c -> IO ()) -> Expectation +ntfTest :: Transport c => TProxy c -> (THandleNTF c 'TClient -> IO ()) -> Expectation ntfTest _ test' = runNtfTest test' `shouldReturn` () data APNSMockRequest = APNSMockRequest diff --git a/tests/NtfServerTests.hs b/tests/NtfServerTests.hs index e7e2018c2..e5f096eef 100644 --- a/tests/NtfServerTests.hs +++ b/tests/NtfServerTests.hs @@ -6,8 +6,8 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} {-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module NtfServerTests where @@ -72,13 +72,13 @@ pattern RespNtf corrId queueId command <- (_, _, (corrId, queueId, Right command deriving instance Eq NtfResponse -sendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c -> (Maybe TransmissionAuth, ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) +sendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) sendRecvNtf h@THandle {params} (sgn, corrId, qId, cmd) = do let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (sgn, tToSend) tGet1 h -signSendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c -> C.APrivateAuthKey -> (ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) +signSendRecvNtf :: forall c e. (Transport c, NtfEntityI e) => THandleNTF c 'TClient -> C.APrivateAuthKey -> (ByteString, ByteString, NtfCommand e) -> IO (SignedTransmission ErrorType NtfResponse) signSendRecvNtf h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (authorize tForAuth, tToSend) diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 3e5e9d2ce..9023574d8 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -67,10 +67,10 @@ xit'' d t = do ci <- runIO $ lookupEnv "CI" (if ci == Just "true" then skip "skipped on CI" . it d else it d) t -testSMPClient :: Transport c => (THandleSMP c -> IO a) -> IO a +testSMPClient :: Transport c => (THandleSMP c 'TClient -> IO a) -> IO a testSMPClient = testSMPClientVR supportedClientSMPRelayVRange -testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c -> IO a) -> IO a +testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a testSMPClientVR vr client = do Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost runTransportClient defaultTransportClientConfig Nothing useHost testPort (Just testKeyHash) $ \h -> do @@ -150,16 +150,16 @@ withSmpServer t = withSmpServerOn t testPort withSmpServerV7 :: HasCallStack => ATransport -> IO a -> IO a withSmpServerV7 t = withSmpServerConfigOn t cfgV7 testPort . const -runSmpTest :: forall c a. (HasCallStack, Transport c) => (HasCallStack => THandleSMP c -> IO a) -> IO a +runSmpTest :: forall c a. (HasCallStack, Transport c) => (HasCallStack => THandleSMP c 'TClient -> IO a) -> IO a runSmpTest test = withSmpServer (transport @c) $ testSMPClient test -runSmpTestN :: forall c a. (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c] -> IO a) -> IO a +runSmpTestN :: forall c a. (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c 'TClient] -> IO a) -> IO a runSmpTestN = runSmpTestNCfg cfg supportedClientSMPRelayVRange -runSmpTestNCfg :: forall c a. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> Int -> (HasCallStack => [THandleSMP c] -> IO a) -> IO a +runSmpTestNCfg :: forall c a. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> Int -> (HasCallStack => [THandleSMP c 'TClient] -> IO a) -> IO a runSmpTestNCfg srvCfg clntVR nClients test = withSmpServerConfigOn (transport @c) srvCfg testPort $ \_ -> run nClients [] where - run :: Int -> [THandleSMP c] -> IO a + run :: Int -> [THandleSMP c 'TClient] -> IO a run 0 hs = test hs run n hs = testSMPClientVR clntVR $ \h -> run (n - 1) (h : hs) @@ -171,7 +171,7 @@ smpServerTest :: IO (Maybe TransmissionAuth, ByteString, ByteString, BrokerMsg) smpServerTest _ t = runSmpTest $ \h -> tPut' h t >> tGet' h where - tPut' :: THandleSMP c -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () + tPut' :: THandleSMP c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, smp) -> IO () tPut' h@THandle {params = THandleParams {sessionId, implySessId}} (sig, corrId, queueId, smp) = do let t' = if implySessId then smpEncode (corrId, queueId, smp) else smpEncode (sessionId, corrId, queueId, smp) [Right ()] <- tPut h [Right (sig, t')] @@ -180,33 +180,33 @@ smpServerTest _ t = runSmpTest $ \h -> tPut' h t >> tGet' h [(Nothing, _, (CorrId corrId, qId, Right cmd))] <- tGet h pure (Nothing, corrId, qId, cmd) -smpTest :: (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> IO ()) -> Expectation +smpTest :: (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> IO ()) -> Expectation smpTest _ test' = runSmpTest test' `shouldReturn` () -smpTestN :: (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c] -> IO ()) -> Expectation +smpTestN :: (HasCallStack, Transport c) => Int -> (HasCallStack => [THandleSMP c 'TClient] -> IO ()) -> Expectation smpTestN n test' = runSmpTestN n test' `shouldReturn` () -smpTest2 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest2 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest2 = smpTest2Cfg cfg supportedClientSMPRelayVRange -smpTest2Cfg :: forall c. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest2Cfg :: forall c. (HasCallStack, Transport c) => ServerConfig -> VersionRangeSMP -> TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest2Cfg srvCfg clntVR _ test' = runSmpTestNCfg srvCfg clntVR 2 _test `shouldReturn` () where - _test :: HasCallStack => [THandleSMP c] -> IO () + _test :: HasCallStack => [THandleSMP c 'TClient] -> IO () _test [h1, h2] = test' h1 h2 _test _ = error "expected 2 handles" -smpTest3 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest3 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest3 _ test' = smpTestN 3 _test where - _test :: HasCallStack => [THandleSMP c] -> IO () + _test :: HasCallStack => [THandleSMP c 'TClient] -> IO () _test [h1, h2, h3] = test' h1 h2 h3 _test _ = error "expected 3 handles" -smpTest4 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c -> THandleSMP c -> THandleSMP c -> THandleSMP c -> IO ()) -> Expectation +smpTest4 :: forall c. (HasCallStack, Transport c) => TProxy c -> (HasCallStack => THandleSMP c 'TClient -> THandleSMP c 'TClient -> THandleSMP c 'TClient -> THandleSMP c 'TClient -> IO ()) -> Expectation smpTest4 _ test' = smpTestN 4 _test where - _test :: HasCallStack => [THandleSMP c] -> IO () + _test :: HasCallStack => [THandleSMP c 'TClient] -> IO () _test [h1, h2, h3, h4] = test' h1 h2 h3 h4 _test _ = error "expected 4 handles" diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index 09cf975c1..b4c40cd99 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -78,13 +78,13 @@ pattern Ids rId sId srvDh <- IDS (QIK rId sId srvDh) pattern Msg :: MsgId -> MsgBody -> BrokerMsg pattern Msg msgId body <- MSG RcvMessage {msgId, msgBody = EncRcvMsgBody body} -sendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c -> (Maybe TransmissionAuth, ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) +sendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c 'TClient -> (Maybe TransmissionAuth, ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) sendRecv h@THandle {params} (sgn, corrId, qId, cmd) = do let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (sgn, tToSend) tGet1 h -signSendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c -> C.APrivateAuthKey -> (ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) +signSendRecv :: forall c p. (Transport c, PartyI p) => THandleSMP c 'TClient -> C.APrivateAuthKey -> (ByteString, ByteString, Command p) -> IO (SignedTransmission ErrorType BrokerMsg) signSendRecv h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd) Right () <- tPut1 h (authorize tForAuth, tToSend) @@ -93,17 +93,17 @@ signSendRecv h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do authorize t = case a of C.SEd25519 -> Just . TASignature . C.ASignature C.SEd25519 $ C.sign' pk t C.SEd448 -> Just . TASignature . C.ASignature C.SEd448 $ C.sign' pk t - C.SX25519 -> (\THandleAuth {peerPubKey} -> TAAuthenticator $ C.cbAuthenticate peerPubKey pk (C.cbNonce corrId) t) <$> thAuth params + C.SX25519 -> (\THAuthClient {serverPeerPubKey = k} -> TAAuthenticator $ C.cbAuthenticate k pk (C.cbNonce corrId) t) <$> thAuth params #if !MIN_VERSION_base(4,18,0) _sx448 -> undefined -- ghc8107 fails to the branch excluded by types #endif -tPut1 :: Transport c => THandle v c -> SentRawTransmission -> IO (Either TransportError ()) +tPut1 :: Transport c => THandle v c 'TClient -> SentRawTransmission -> IO (Either TransportError ()) tPut1 h t = do [r] <- tPut h [Right t] pure r -tGet1 :: (ProtocolEncoding v err cmd, Transport c) => THandle v c -> IO (SignedTransmission err cmd) +tGet1 :: (ProtocolEncoding v err cmd, Transport c) => THandle v c 'TClient -> IO (SignedTransmission err cmd) tGet1 h = do [r] <- liftIO $ tGet h pure r @@ -555,12 +555,12 @@ testWithStoreLog at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 1 removeFile testStoreLogFile where - runTest :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> ThreadId -> Expectation + runTest :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> ThreadId -> Expectation runTest _ test' server = do testSMPClient test' `shouldReturn` () killThread server - runClient :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> Expectation + runClient :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> Expectation runClient _ test' = testSMPClient test' `shouldReturn` () logSize :: FilePath -> IO Int @@ -653,12 +653,12 @@ testRestoreMessages at@(ATransport t) = removeFile testStoreMsgsFile removeFile testServerStatsBackupFile where - runTest :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> ThreadId -> Expectation + runTest :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> ThreadId -> Expectation runTest _ test' server = do testSMPClient test' `shouldReturn` () killThread server - runClient :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> Expectation + runClient :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> Expectation runClient _ test' = testSMPClient test' `shouldReturn` () checkStats :: ServerStatsData -> [RecipientId] -> Int -> Int -> Expectation @@ -727,15 +727,15 @@ testRestoreExpireMessages at@(ATransport t) = Right ServerStatsData {_msgExpired} <- strDecode <$> B.readFile testServerStatsBackupFile _msgExpired `shouldBe` 2 where - runTest :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> ThreadId -> Expectation + runTest :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> ThreadId -> Expectation runTest _ test' server = do testSMPClient test' `shouldReturn` () killThread server - runClient :: Transport c => TProxy c -> (THandleSMP c -> IO ()) -> Expectation + runClient :: Transport c => TProxy c -> (THandleSMP c 'TClient -> IO ()) -> Expectation runClient _ test' = testSMPClient test' `shouldReturn` () -createAndSecureQueue :: Transport c => THandleSMP c -> SndPublicAuthKey -> IO (SenderId, RecipientId, RcvPrivateAuthKey, RcvDhSecret) +createAndSecureQueue :: Transport c => THandleSMP c 'TClient -> SndPublicAuthKey -> IO (SenderId, RecipientId, RcvPrivateAuthKey, RcvDhSecret) createAndSecureQueue h sPub = do g <- C.newRandom (rPub, rKey) <- atomically $ C.generateAuthKeyPair C.SEd448 g @@ -759,8 +759,8 @@ testTiming (ATransport t) = timingTests :: [(C.AuthAlg, C.AuthAlg, Int)] timingTests = [ (C.AuthAlg C.SEd25519, C.AuthAlg C.SEd25519, 200), -- correct key type - -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SEd448, 150), - -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SX25519, 200), + -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SEd448, 150), + -- (C.AuthAlg C.SEd25519, C.AuthAlg C.SX25519, 200), (C.AuthAlg C.SEd448, C.AuthAlg C.SEd25519, 200), (C.AuthAlg C.SEd448, C.AuthAlg C.SEd448, 150), -- correct key type (C.AuthAlg C.SEd448, C.AuthAlg C.SX25519, 200), @@ -770,7 +770,7 @@ testTiming (ATransport t) = ] timeRepeat n = fmap fst . timeItT . forM_ (replicate n ()) . const similarTime t1 t2 = abs (t2 / t1 - 1) < 0.2 -- normally the difference between "no queue" and "wrong key" is less than 5% - testSameTiming :: forall c. Transport c => THandleSMP c -> THandleSMP c -> (C.AuthAlg, C.AuthAlg, Int) -> Expectation + testSameTiming :: forall c. Transport c => THandleSMP c 'TClient -> THandleSMP c 'TClient -> (C.AuthAlg, C.AuthAlg, Int) -> Expectation testSameTiming rh sh (C.AuthAlg goodKeyAlg, C.AuthAlg badKeyAlg, n) = do g <- C.newRandom (rPub, rKey) <- atomically $ C.generateAuthKeyPair goodKeyAlg g @@ -791,10 +791,11 @@ testTiming (ATransport t) = runTimingTest sh badKey sId $ _SEND "hello" where - runTimingTest :: PartyI p => THandleSMP c -> C.APrivateAuthKey -> ByteString -> Command p -> IO () + runTimingTest :: PartyI p => THandleSMP c 'TClient -> C.APrivateAuthKey -> ByteString -> Command p -> IO () runTimingTest h badKey qId cmd = do threadDelay 100000 - _ <- timeRepeat n $ do -- "warm up" the server + _ <- timeRepeat n $ do + -- "warm up" the server Resp "dabc" _ (ERR AUTH) <- signSendRecv h badKey ("dabc", "1234", cmd) return () threadDelay 100000 From a3b229f668bc39da24b206c3e7f2f0e567717498 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 18 Apr 2024 22:35:17 +0100 Subject: [PATCH 008/107] SMP proxy: low level client and server implementation (#1096) * SMP proxy: low level client and server implementation * SMP proxy: server implementation (#1098) * wip * PRXY command * progress * SMP Proxy: client-level implementation (#1101) * buildable * encode messages * update pkey * fix queue types * wrap SEND in proxy lookup * WIP proxy client * WIP * post-rebase fixes * encode something with something * cleanup * update * fix nonce/corrId in batchingTests * WIP: dig into createSMPProxySession * agent * test progress * pass the test * parameterize transport handle with transport peer to include server certificate (#1100) * parameterize transport handle with transport peer to include server certificate * include server certificate into THandle * load server chain and sign key * fix key type * fix for 8.10 --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Co-authored-by: IC Rainbow * cleanup * add 2-server test * remove subsumed test * checkCredentials for BrokerMsg * skip batching tests * remove userId param * remove agent changes --------- Co-authored-by: Evgeny Poberezkin --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> * remove unused type * icrease test timeout * reduce transport block * envelope sizes * don't fork unless have proxied commands to process --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Co-authored-by: IC Rainbow --- .github/workflows/build.yml | 2 +- src/Simplex/FileTransfer/Client.hs | 4 +- src/Simplex/FileTransfer/Protocol.hs | 3 +- src/Simplex/Messaging/Agent.hs | 2 +- src/Simplex/Messaging/Client.hs | 149 ++++++++++++-- src/Simplex/Messaging/Client/Agent.hs | 21 +- src/Simplex/Messaging/Crypto.hs | 4 + src/Simplex/Messaging/Protocol.hs | 185 ++++++++++++++---- src/Simplex/Messaging/Server.hs | 185 +++++++++++++----- src/Simplex/Messaging/Server/Env/STM.hs | 35 ++-- src/Simplex/Messaging/Server/Main.hs | 7 +- .../Messaging/Server/QueueStore/STM.hs | 2 +- src/Simplex/Messaging/Transport.hs | 6 + tests/CoreTests/BatchingTests.hs | 12 +- tests/CoreTests/ProtocolErrorTests.hs | 10 +- tests/SMPClient.hs | 11 +- tests/SMPProxyTests.hs | 117 ++++++++--- tests/Test.hs | 2 +- 18 files changed, 583 insertions(+), 174 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4c44cff9..4a61490ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: run: cabal build --enable-tests - name: Test - timeout-minutes: 30 + timeout-minutes: 40 shell: bash run: cabal test --test-show-details=direct diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index a223d492e..2606d1bb9 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -186,9 +186,11 @@ xftpClientError = \case sendXFTPCommand :: forall p. FilePartyI p => XFTPClient -> C.APrivateAuthKey -> XFTPFileId -> FileCommand p -> Maybe XFTPChunkSpec -> ExceptT XFTPClientError IO (FileResponse, HTTP2Body) sendXFTPCommand c@XFTPClient {thParams} pKey fId cmd chunkSpec_ = do + -- TODO random corrId + let corrIdUsedAsNonce = "" t <- liftEither . first PCETransportError $ - xftpEncodeAuthTransmission thParams pKey ("", fId, FileCmd (sFileParty @p) cmd) + xftpEncodeAuthTransmission thParams pKey (corrIdUsedAsNonce, fId, FileCmd (sFileParty @p) cmd) sendXFTPTransmission c t chunkSpec_ sendXFTPTransmission :: XFTPClient -> ByteString -> Maybe XFTPChunkSpec -> ExceptT XFTPClientError IO (FileResponse, HTTP2Body) diff --git a/src/Simplex/FileTransfer/Protocol.hs b/src/Simplex/FileTransfer/Protocol.hs index 418e48482..c55b327f8 100644 --- a/src/Simplex/FileTransfer/Protocol.hs +++ b/src/Simplex/FileTransfer/Protocol.hs @@ -48,6 +48,7 @@ import Simplex.Messaging.Protocol SndPublicAuthKey, Transmission, TransmissionForAuth (..), + CorrId (..), encodeTransmission, encodeTransmissionForAuth, messageTagP, @@ -328,7 +329,7 @@ checkParty' c = case testEquality (sFileParty @p) (sFileParty @p') of xftpEncodeAuthTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion 'TClient -> C.APrivateAuthKey -> Transmission c -> Either TransportError ByteString xftpEncodeAuthTransmission thParams@THandleParams {thAuth} pKey (corrId, fId, msg) = do let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, fId, msg) - xftpEncodeBatch1 . (,tToSend) =<< authTransmission thAuth (Just pKey) corrId tForAuth + xftpEncodeBatch1 . (,tToSend) =<< authTransmission thAuth (Just pKey) (C.cbNonce $ bs corrId) tForAuth xftpEncodeTransmission :: ProtocolEncoding XFTPVersion e c => THandleParams XFTPVersion p -> Transmission c -> Either TransportError ByteString xftpEncodeTransmission thParams (corrId, fId, msg) = do diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e0cfb2aca..2d9fedc65 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -788,7 +788,7 @@ compatibleContactUri (CRContactUri ConnReqUriData {crAgentVRange, crSmpQueues = AgentConfig {smpClientVRange, smpAgentVRange} <- asks config pure $ (,) - <$> (qUri `compatibleVersion` smpClientVRange) + <$> (qUri `compatibleVersion` smpClientVRange) <*> (crAgentVRange `compatibleVersion` smpAgentVRange pqSup) versionPQSupport_ :: VersionSMPA -> Maybe CR.VersionE2E -> PQSupport diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 61ec96003..1c7196495 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -54,6 +54,9 @@ module Simplex.Messaging.Client suspendSMPQueue, deleteSMPQueue, deleteSMPQueues, + createSMPProxySession, + proxySMPMessage, + forwardSMPMessage, sendProtocolCommand, -- * Supporting types and client configuration @@ -69,6 +72,7 @@ module Simplex.Messaging.Client chooseTransportHost, proxyUsername, temporaryClientError, + smpProxyError, ServerTransmission, ClientCommand, @@ -98,9 +102,12 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import qualified Data.X509 as X +import qualified Data.X509.Validation as XV import Network.Socket (ServiceName) import Numeric.Natural import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) import Simplex.Messaging.Protocol @@ -110,7 +117,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (SocksProxy, TransportClientConfig (..), TransportHost (..), runTransportClient) import Simplex.Messaging.Transport.KeepAlive import Simplex.Messaging.Transport.WebSockets (WS) -import Simplex.Messaging.Util (bshow, raceAny_, threadDelay') +import Simplex.Messaging.Util (bshow, liftEitherWith, raceAny_, threadDelay') import Simplex.Messaging.Version import System.Timeout (timeout) @@ -480,6 +487,19 @@ temporaryClientError = \case _ -> False {-# INLINE temporaryClientError #-} +-- TODO keep error params +smpProxyError :: SMPClientError -> ErrorType +smpProxyError = \case + PCEProtocolError _ -> PROXY PROTOCOL + PCEResponseError _ -> PROXY RESPONSE + PCEUnexpectedResponse _ -> PROXY UNEXPECTED + PCEResponseTimeout -> PROXY TIMEOUT + PCENetworkError -> PROXY NETWORK + PCEIncompatibleHost -> PROXY BAD_HOST + PCETransportError _ -> PROXY TRANSPORT + PCECryptoError _ -> INTERNAL + PCEIOError _ -> INTERNAL + -- | Create a new SMP queue. -- -- https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#create-queue-command @@ -630,6 +650,102 @@ deleteSMPQueues :: SMPClient -> NonEmpty (RcvPrivateAuthKey, RecipientId) -> IO deleteSMPQueues = okSMPCommands DEL {-# INLINE deleteSMPQueues #-} +-- TODO picture + +-- send PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender +-- receives PKEY :: SessionId -> X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg +createSMPProxySession :: SMPClient -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO (SessionId, VersionSMP, C.PublicKeyX25519) +createSMPProxySession c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth = + sendSMPCommand c Nothing "" (PRXY relayServ proxyAuth) >>= \case + -- XXX: rfc says sessionId should be in the entityId of response + PKEY sId vr (chain, key) -> do + case supportedClientSMPRelayVRange `compatibleVersion` vr of + Nothing -> throwE PCEIncompatibleHost -- TODO different error + Just (Compatible v) -> liftEitherWith x509Error $ (sId,v,) <$> validateRelay chain key + r -> throwE . PCEUnexpectedResponse $ bshow r + where + x509Error :: String -> SMPClientError + x509Error _msg = PCEResponseError $ error "TODO: x509 error" -- TODO different error + validateRelay :: X.CertificateChain -> X.SignedExact X.PubKey -> Either String C.PublicKeyX25519 + validateRelay (X.CertificateChain cert) exact = do + serverKey <- case cert of + [leaf, ca] + | XV.Fingerprint kh == XV.getFingerprint ca X.HashSHA256 -> + C.x509ToPublic (X.certPubKey . X.signedObject $ X.getSigned leaf, []) >>= C.pubKey + _ -> throwError "bad certificate" + pubKey <- C.verifyX509 serverKey exact + C.x509ToPublic (pubKey, []) >>= C.pubKey + +-- consider how to process slow responses - is it handled somehow locally or delegated to the caller +-- this method is used in the client +-- sends PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command Sender +-- receives PRES :: EncResponse -> BrokerMsg -- proxy to client +proxySMPMessage :: + SMPClient -> + -- proxy session from PKEY + SessionId -> + VersionSMP -> + C.PublicKeyX25519 -> + -- message to deliver + Maybe SndPrivateAuthKey -> + SenderId -> + MsgFlags -> + MsgBody -> + ExceptT SMPClientError IO () +-- TODO use version +proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} sessionId _v serverKey spKey sId flags msg = do + -- prepare params + let serverThAuth = (\ta -> ta {serverPeerPubKey = serverKey}) <$> thAuth proxyThParams + serverThParams = proxyThParams {sessionId, thAuth = serverThAuth} + (cmdPubKey, cmdPrivKey) <- liftIO . atomically $ C.generateKeyPair @'C.X25519 g + let cmdSecret = C.dh' serverKey cmdPrivKey + nonce@(C.CbNonce corrId) <- liftIO . atomically $ C.randomCbNonce g + -- encode + let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth serverThParams (CorrId corrId, sId, Cmd SSender $ SEND flags msg) + auth <- liftEitherWith PCETransportError $ authTransmission serverThAuth spKey nonce tForAuth + b <- case batchTransmissions (batch serverThParams) (blockSize serverThParams) [Right (auth, tToSend)] of + [] -> throwE $ PCETransportError TELargeMsg -- some other error. Internal? + TBError e _ : _ -> throwE $ PCETransportError e -- large message error? + TBTransmission s _ : _ -> pure s + TBTransmissions s _ _ : _ -> pure s + et <- liftEitherWith PCECryptoError $ EncTransmission <$> C.cbEncrypt cmdSecret nonce b paddedProxiedMsgLength + sendProtocolCommand_ c (Just nonce) Nothing sessionId (Cmd SProxiedClient (PFWD cmdPubKey et)) >>= \case + -- TODO support PKEY + resend? + PRES (EncResponse er) -> do + t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (C.reverseNonce nonce) er + case tParse proxyThParams t' of + t'' :| [] -> case tDecodeParseValidate proxyThParams t'' of + (_auth, _signed, (_c, _e, r)) -> case r of -- TODO: verify + Left e -> throwE $ PCEResponseError e + Right OK -> pure () + Right (ERR e) -> throwE $ PCEProtocolError e + Right u -> throwE . PCEUnexpectedResponse $ bshow u -- possibly differentiate unexpected response from server/proxy + _ -> throwE $ PCETransportError TEBadBlock + r -> throwE . PCEUnexpectedResponse $ bshow r -- from proxy + +-- this method is used in the proxy +-- sends RFWD :: EncFwdTransmission -> Command Sender +-- receives RRES :: EncFwdResponse -> BrokerMsg +-- proxy should send PRES to the client with EncResponse +forwardSMPMessage :: SMPClient -> CorrId -> C.PublicKeyX25519 -> EncTransmission -> ExceptT SMPClientError IO EncResponse +forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = g}} fwdCorrId fwdKey fwdTransmission = do + -- prepare params + sessSecret <- case thAuth thParams of + Nothing -> throwError $ PCEProtocolError INTERNAL -- different error - proxy didn't pass key? + Just THAuthClient {serverPeerPubKey, clientPrivKey} -> pure $ C.dh' serverPeerPubKey clientPrivKey + nonce <- liftIO . atomically $ C.randomCbNonce g + -- wrap + let fwdT = FwdTransmission {fwdCorrId, fwdKey, fwdTransmission} + eft <- liftEitherWith PCECryptoError $ EncFwdTransmission <$> C.cbEncrypt sessSecret nonce (smpEncode fwdT) paddedForwardedMsgLength + -- send + sendProtocolCommand_ c (Just nonce) Nothing "" (Cmd SSender (RFWD eft)) >>= \case + RRES (EncFwdResponse efr) -> do + -- unwrap + r' <- liftEitherWith PCECryptoError $ C.cbDecrypt sessSecret (C.reverseNonce nonce) efr + FwdResponse {fwdCorrId = _, fwdResponse} <- liftEitherWith (const $ PCEResponseError BLOCK) $ smpDecode r' + pure fwdResponse + r -> throwE . PCEUnexpectedResponse $ bshow r + okSMPCommand :: PartyI p => Command p -> SMPClient -> C.APrivateAuthKey -> QueueId -> ExceptT SMPClientError IO () okSMPCommand cmd c pKey qId = sendSMPCommand c (Just pKey) qId cmd >>= \case @@ -693,8 +809,11 @@ sendBatch c@ProtocolClient {client_ = PClient {sndQ}} b = do -- | Send Protocol command sendProtocolCommand :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg -sendProtocolCommand c@ProtocolClient {client_ = PClient {sndQ}, thParams = THandleParams {batch, blockSize}} pKey entId cmd = - ExceptT $ uncurry sendRecv =<< mkTransmission c (pKey, entId, cmd) +sendProtocolCommand c = sendProtocolCommand_ c Nothing + +sendProtocolCommand_ :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.CbNonce -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg +sendProtocolCommand_ c@ProtocolClient {client_ = PClient {sndQ}, thParams = THandleParams {batch, blockSize}} nonce_ pKey entId cmd = + ExceptT $ uncurry sendRecv =<< mkTransmission_ c nonce_ (pKey, entId, cmd) where -- two separate "atomically" needed to avoid blocking sendRecv :: Either TransportError SentRawTransmission -> Request err msg -> IO (Either (ProtocolClientError err) msg) @@ -713,33 +832,35 @@ getResponse :: ProtocolClient v err msg -> Request err msg -> IO (Response err m getResponse ProtocolClient {client_ = PClient {tcpTimeout, pingErrorCount}} Request {entityId, responseVar} = do response <- timeout tcpTimeout (atomically (takeTMVar responseVar)) >>= \case + -- BTW: another registerDelay candidate. Also, crashes caller with BlockedIndef. Just r -> atomically (writeTVar pingErrorCount 0) $> r Nothing -> pure $ Left PCEResponseTimeout pure Response {entityId, response} -mkTransmission :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> ClientCommand msg -> IO (PCTransmission err msg) -mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCommands}} (pKey_, entId, cmd) = do - corrId <- atomically getNextCorrId - let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, entId, cmd) - auth = authTransmission (thAuth thParams) pKey_ corrId tForAuth - r <- atomically $ mkRequest corrId +mkTransmission :: ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> ClientCommand msg -> IO (PCTransmission err msg) +mkTransmission c = mkTransmission_ c Nothing + +mkTransmission_ :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.CbNonce -> ClientCommand msg -> IO (PCTransmission err msg) +mkTransmission_ ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCommands}} nonce_ (pKey_, entId, cmd) = do + nonce@(C.CbNonce corrId) <- maybe (atomically $ C.randomCbNonce clientCorrId) pure nonce_ + let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (CorrId corrId, entId, cmd) + auth = authTransmission (thAuth thParams) pKey_ nonce tForAuth + r <- atomically $ mkRequest (CorrId corrId) pure ((,tToSend) <$> auth, r) where - getNextCorrId :: STM CorrId - getNextCorrId = CorrId <$> C.randomBytes 24 clientCorrId -- also used as nonce mkRequest :: CorrId -> STM (Request err msg) mkRequest corrId = do r <- Request entId <$> newEmptyTMVar TM.insert corrId r sentCommands pure r -authTransmission :: Maybe (THandleAuth 'TClient) -> Maybe C.APrivateAuthKey -> CorrId -> ByteString -> Either TransportError (Maybe TransmissionAuth) -authTransmission thAuth pKey_ (CorrId corrId) t = traverse authenticate pKey_ +authTransmission :: Maybe (THandleAuth 'TClient) -> Maybe C.APrivateAuthKey -> C.CbNonce -> ByteString -> Either TransportError (Maybe TransmissionAuth) +authTransmission thAuth pKey_ nonce t = traverse authenticate pKey_ where authenticate :: C.APrivateAuthKey -> Either TransportError TransmissionAuth authenticate (C.APrivateAuthKey a pk) = case a of C.SX25519 -> case thAuth of - Just THAuthClient {serverPeerPubKey = k} -> Right $ TAAuthenticator $ C.cbAuthenticate k pk (C.cbNonce corrId) t + Just THAuthClient {serverPeerPubKey = k} -> Right $ TAAuthenticator $ C.cbAuthenticate k pk nonce t Nothing -> Left TENoServerAuth C.SEd25519 -> sign pk C.SEd448 -> sign pk diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 4b925c6f6..50f12ac35 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -98,6 +98,7 @@ data SMPClientAgent = SMPClientAgent agentQ :: TBQueue SMPClientAgentEvent, randomDrg :: TVar ChaChaDRG, smpClients :: TMap SMPServer SMPClientVar, + smpSessions :: TMap SessionId SMPClient, srvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), pendingSrvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), reconnections :: TVar [Async ()], @@ -135,6 +136,7 @@ newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg msgQ <- newTBQueue msgQSize agentQ <- newTBQueue agentQSize smpClients <- TM.empty + smpSessions <- TM.empty srvSubs <- TM.empty pendingSrvSubs <- TM.empty reconnections <- newTVar [] @@ -147,6 +149,7 @@ newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg agentQ, randomDrg, smpClients, + smpSessions, srvSubs, pendingSrvSubs, reconnections, @@ -155,7 +158,7 @@ newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg } getSMPServerClient' :: SMPClientAgent -> SMPServer -> ExceptT SMPClientError IO SMPClient -getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, workerSeq} srv = +getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, randomDrg, workerSeq} srv = atomically getClientVar >>= either newSMPClient waitForSMPClient where getClientVar :: STM (Either SMPClientVar SMPClientVar) @@ -178,7 +181,9 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, wo tryE (connectClient v) >>= \r -> case r of Right smp -> do logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv - atomically $ putTMVar (sessionVar v) r + atomically $ do + putTMVar (sessionVar v) r + TM.insert (sessionId $ thParams smp) smp smpSessions successAction smp Left e -> do if e == PCENetworkError || e == PCEResponseTimeout @@ -200,13 +205,14 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, wo connectClient v = ExceptT $ getProtocolClient randomDrg (1, srv, Nothing) (smpCfg agentCfg) (Just msgQ) (clientDisconnected v) clientDisconnected :: SMPClientVar -> SMPClient -> IO () - clientDisconnected v _ = do - removeClientAndSubs v >>= (`forM_` serverDown) + clientDisconnected v smp = do + removeClientAndSubs v smp >>= (`forM_` serverDown) logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv - removeClientAndSubs :: SMPClientVar -> IO (Maybe (Map SMPSub C.APrivateAuthKey)) - removeClientAndSubs v = atomically $ do + removeClientAndSubs :: SMPClientVar -> SMPClient -> IO (Maybe (Map SMPSub C.APrivateAuthKey)) + removeClientAndSubs v smp = atomically $ do removeSessVar v srv smpClients + TM.delete (sessionId $ thParams smp) smpSessions TM.lookupDelete srv (srvSubs ca) >>= mapM updateSubs where updateSubs sVar = do @@ -271,6 +277,9 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, wo notify :: SMPClientAgentEvent -> IO () notify evt = atomically $ writeTBQueue (agentQ ca) evt +lookupSMPServerClient :: SMPClientAgent -> SessionId -> STM (Maybe SMPClient) +lookupSMPServerClient SMPClientAgent {smpSessions} sessId = TM.lookup sessId smpSessions + closeSMPClientAgent :: SMPClientAgent -> IO () closeSMPClientAgent c = do closeSMPServerClients c diff --git a/src/Simplex/Messaging/Crypto.hs b/src/Simplex/Messaging/Crypto.hs index bffd7559f..7aefbd709 100644 --- a/src/Simplex/Messaging/Crypto.hs +++ b/src/Simplex/Messaging/Crypto.hs @@ -141,6 +141,7 @@ module Simplex.Messaging.Crypto sbEncrypt_, cbNonce, randomCbNonce, + reverseNonce, -- * NaCl crypto_secretbox SbKey (unSbKey), @@ -1292,6 +1293,9 @@ randomCbNonce = fmap CryptoBoxNonce . randomBytes 24 randomBytes :: Int -> TVar ChaChaDRG -> STM ByteString randomBytes n gVar = stateTVar gVar $ randomBytesGenerate n +reverseNonce :: CbNonce -> CbNonce +reverseNonce (CryptoBoxNonce s) = CryptoBoxNonce (B.reverse s) + instance Encoding CbNonce where smpEncode = unCbNonce smpP = CryptoBoxNonce <$> A.take 24 diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index b5a3e7770..459f9a0df 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -43,6 +43,8 @@ module Simplex.Messaging.Protocol ( -- * SMP protocol parameters supportedSMPClientVRange, maxMessageLength, + paddedProxiedMsgLength, + paddedForwardedMsgLength, e2eEncConfirmationLength, e2eEncMessageLength, @@ -56,6 +58,7 @@ module Simplex.Messaging.Protocol SubscriptionMode (..), Party (..), Cmd (..), + DirectParty, BrokerMsg (..), SParty (..), PartyI (..), @@ -63,6 +66,7 @@ module Simplex.Messaging.Protocol ProtocolErrorType (..), ErrorType (..), CommandError (..), + ProxyError (..), Transmission, TransmissionAuth (..), SignedTransmission, @@ -121,6 +125,12 @@ module Simplex.Messaging.Protocol EncNMsgMeta, SMPMsgMeta (..), NMsgMeta (..), + EncFwdResponse (..), + EncFwdTransmission (..), + EncResponse (..), + EncTransmission (..), + FwdResponse (..), + FwdTransmission (..), MsgFlags (..), initialSMPClientVersion, userProtocol, @@ -191,6 +201,7 @@ import Data.Word (Word16) import qualified Data.X509 as X import GHC.TypeLits (ErrorMessage (..), TypeError, type (+)) import qualified GHC.TypeLits as TE +import qualified GHC.TypeLits as Type import Network.Socket (ServiceName) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding @@ -233,6 +244,20 @@ supportedSMPClientVRange = mkVersionRange initialSMPClientVersion currentSMPClie maxMessageLength :: Int maxMessageLength = 16088 +-- without signature works with min 16151 (fails with 16150) +-- with Ed448: 16265 (fails with 16264) +-- with Ed25519: 16215 (fails with 16214) +-- with X25519: 16232 (fails with 16231) +paddedProxiedMsgLength :: Int +paddedProxiedMsgLength = 16232 + +-- without signature works with min 16239 (fails with 16238) +-- with Ed448: 16353 (fails with 16352) +-- with Ed25519: 16303 (fails with 16302) +-- with X25519: 16320 (fails with 16319) +paddedForwardedMsgLength :: Int +paddedForwardedMsgLength = 16320 + type MaxMessageLen = 16088 -- 16 extra bytes: 8 for timestamp and 8 for flags (7 flags and the space, only 1 flag is currently used) @@ -246,7 +271,7 @@ e2eEncMessageLength :: Int e2eEncMessageLength = 16032 -- | SMP protocol clients -data Party = Recipient | Sender | Notifier +data Party = Recipient | Sender | Notifier | ProxiedClient deriving (Show) -- | Singleton types for SMP protocol clients @@ -254,11 +279,13 @@ data SParty :: Party -> Type where SRecipient :: SParty Recipient SSender :: SParty Sender SNotifier :: SParty Notifier + SProxiedClient :: SParty ProxiedClient instance TestEquality SParty where testEquality SRecipient SRecipient = Just Refl testEquality SSender SSender = Just Refl testEquality SNotifier SNotifier = Just Refl + testEquality SProxiedClient SProxiedClient = Just Refl testEquality _ _ = Nothing deriving instance Show (SParty p) @@ -271,6 +298,15 @@ instance PartyI Sender where sParty = SSender instance PartyI Notifier where sParty = SNotifier +instance PartyI ProxiedClient where sParty = SProxiedClient + +type family DirectParty (p :: Party) :: Constraint where + DirectParty Recipient = () + DirectParty Sender = () + DirectParty Notifier = () + DirectParty p = + (Int ~ Bool, TypeError (Type.Text "Party " :<>: ShowType p :<>: Type.Text " is not direct")) + -- | Type for client command of any participant. data Cmd = forall p. PartyI p => Cmd (SParty p) (Command p) @@ -361,13 +397,13 @@ data Command (p :: Party) where PING :: Command Sender -- SMP notification subscriber commands NSUB :: Command Notifier - PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- request a relay server connection by URI + PRXY :: SMPServer -> Maybe BasicAuth -> Command ProxiedClient -- request a relay server connection by URI -- Transmission to proxy: -- - entity ID: ID of the session with relay returned in PKEY (response to PRXY) -- - corrId: also used as a nonce to encrypt transmission to relay, corrId + 1 - from relay -- - key (1st param in the command) is used to agree DH secret for this particular transmission and its response -- Encrypted transmission should include session ID (tlsunique) from proxy-relay connection. - PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command Sender -- use CorrId as CbNonce, client to proxy + PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command ProxiedClient -- use CorrId as CbNonce, client to proxy -- Transmission forwarded to relay: -- - entity ID: empty -- - corrId: unique correlation ID between proxy and relay, also used as a nonce to encrypt forwarded transmission @@ -401,11 +437,18 @@ newtype EncTransmission = EncTransmission ByteString deriving (Show) data FwdTransmission = FwdTransmission - { fwdCorrId :: ByteString, + { fwdCorrId :: CorrId, fwdKey :: C.PublicKeyX25519, - fwdTransmission :: ByteString + fwdTransmission :: EncTransmission } +instance Encoding FwdTransmission where + smpEncode FwdTransmission {fwdCorrId = CorrId corrId, fwdKey, fwdTransmission = EncTransmission t} = + smpEncode (corrId, fwdKey, Tail t) + smpP = do + (corrId, fwdKey, Tail t) <- smpP + pure FwdTransmission {fwdCorrId = CorrId corrId, fwdKey, fwdTransmission = EncTransmission t} + newtype EncFwdTransmission = EncFwdTransmission ByteString deriving (Show) @@ -419,7 +462,7 @@ data BrokerMsg where NID :: NotifierId -> RcvNtfPublicDhKey -> BrokerMsg NMSG :: C.CbNonce -> EncNMsgMeta -> BrokerMsg -- Should include certificate chain - PKEY :: X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg -- TLS-signed server key for proxy shared secret and initial sender key + PKEY :: SessionId -> VersionRangeSMP -> (X.CertificateChain, X.SignedExact X.PubKey) -> BrokerMsg -- TLS-signed server key for proxy shared secret and initial sender key RRES :: EncFwdResponse -> BrokerMsg -- relay to proxy PRES :: EncResponse -> BrokerMsg -- proxy to client END :: BrokerMsg @@ -438,10 +481,17 @@ newtype EncFwdResponse = EncFwdResponse ByteString deriving (Eq, Show) data FwdResponse = FwdResponse - { fwdCorrId :: ByteString, - fwdResponse :: ByteString + { fwdCorrId :: CorrId, + fwdResponse :: EncResponse } +instance Encoding FwdResponse where + smpEncode FwdResponse {fwdCorrId = CorrId corrId, fwdResponse = EncResponse t} = + smpEncode (corrId, Tail t) + smpP = do + (corrId, Tail t) <- smpP + pure FwdResponse {fwdCorrId = CorrId corrId, fwdResponse = EncResponse t} + newtype EncResponse = EncResponse ByteString deriving (Eq, Show) @@ -607,8 +657,8 @@ data CommandTag (p :: Party) where DEL_ :: CommandTag Recipient SEND_ :: CommandTag Sender PING_ :: CommandTag Sender - PRXY_ :: CommandTag Sender - PFWD_ :: CommandTag Sender + PRXY_ :: CommandTag ProxiedClient + PFWD_ :: CommandTag ProxiedClient RFWD_ :: CommandTag Sender NSUB_ :: CommandTag Notifier @@ -672,8 +722,8 @@ instance ProtocolMsgTag CmdTag where "DEL" -> Just $ CT SRecipient DEL_ "SEND" -> Just $ CT SSender SEND_ "PING" -> Just $ CT SSender PING_ - "PRXY" -> Just $ CT SSender PRXY_ - "PFWD" -> Just $ CT SSender PFWD_ + "PRXY" -> Just $ CT SProxiedClient PRXY_ + "PFWD" -> Just $ CT SProxiedClient PFWD_ "RFWD" -> Just $ CT SSender RFWD_ "NSUB" -> Just $ CT SNotifier NSUB_ _ -> Nothing @@ -1096,6 +1146,8 @@ data ErrorType SESSION | -- | SMP command is unknown or has invalid syntax CMD {cmdErr :: CommandError} + | -- | error from proxied relay + PROXY {proxyErr :: ProxyError} | -- | command authorization error - bad signature or non-existing SMP queue AUTH | -- | SMP queue capacity is exceeded on the server @@ -1115,8 +1167,12 @@ data ErrorType instance StrEncoding ErrorType where strEncode = \case CMD e -> "CMD " <> bshow e + PROXY e -> "PROXY " <> bshow e e -> bshow e - strP = "CMD " *> (CMD <$> parseRead1) <|> parseRead1 + strP = + "CMD " *> (CMD <$> parseRead1) + <|> "PROXY " *> (PROXY <$> parseRead1) + <|> parseRead1 -- | SMP command error type. data CommandError @@ -1134,6 +1190,22 @@ data CommandError NO_ENTITY deriving (Eq, Read, Show) +-- TODO keep error params +data ProxyError + = -- | Correctly parsed SMP server ERR response. + -- This error is forwarded to the agent client as `ERR SMP err`. + PROTOCOL -- {protocolErr :: String} + | -- | Invalid server response that failed to parse. + -- Forwarded to the agent client as `ERR BROKER RESPONSE`. + RESPONSE -- {responseErr :: String} + | UNEXPECTED + | TIMEOUT + | NETWORK + | BAD_HOST + | NO_SESSION + | TRANSPORT -- {transportErr :: TransportError} + deriving (Eq, Read, Show) + -- | SMP transmission parser. transmissionP :: THandleParams v p -> Parser RawTransmission transmissionP THandleParams {sessionId, implySessId} = do @@ -1195,9 +1267,9 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where SEND flags msg -> e (SEND_, ' ', flags, ' ', Tail msg) PING -> e PING_ NSUB -> e NSUB_ - PRXY host auth_ -> e (PRXY_, ' ', strEncode host, ' ', auth_) - PFWD {} -> error "TODO: e (PFWD_,,)" - RFWD {} -> error "TODO: e (RFWD_,,)" + PRXY host auth_ -> e (PRXY_, ' ', host, auth_) + PFWD pubKey (EncTransmission s) -> e (PFWD_, ' ', pubKey, Tail s) + RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) where e :: Encoding a => a -> ByteString e = smpEncode @@ -1207,27 +1279,33 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg {-# INLINE fromProtocolError #-} - checkCredentials (auth, _, queueId, _) cmd = case cmd of + checkCredentials (auth, _, entId, _) cmd = case cmd of -- NEW must have signature but NOT queue ID NEW {} | isNothing auth -> Left $ CMD NO_AUTH - | not (B.null queueId) -> Left $ CMD HAS_AUTH + | not (B.null entId) -> Left $ CMD HAS_AUTH | otherwise -> Right cmd -- SEND must have queue ID, signature is not always required SEND {} - | B.null queueId -> Left $ CMD NO_ENTITY + | B.null entId -> Left $ CMD NO_ENTITY | otherwise -> Right cmd - -- PING must not have queue ID or signature - PING - | isNothing auth && B.null queueId -> Right cmd - | otherwise -> Left $ CMD HAS_AUTH - PRXY {} - | isNothing auth && B.null queueId -> Right cmd + PING -> noAuthCmd + PRXY {} -> noAuthCmd + PFWD {} + | B.null entId -> Left $ CMD NO_ENTITY + | isNothing auth -> Right cmd | otherwise -> Left $ CMD HAS_AUTH + RFWD _ -> noAuthCmd -- other client commands must have both signature and queue ID _ - | isNothing auth || B.null queueId -> Left $ CMD NO_AUTH + | isNothing auth || B.null entId -> Left $ CMD NO_AUTH | otherwise -> Right cmd + where + -- command must not have entity ID (queue or session ID) or signature + noAuthCmd :: Either ErrorType (Command p) + noAuthCmd + | isNothing auth && B.null entId = Right cmd + | otherwise = Left $ CMD HAS_AUTH instance ProtocolEncoding SMPVersion ErrorType Cmd where type Tag Cmd = CmdTag @@ -1255,9 +1333,11 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where Cmd SSender <$> case tag of SEND_ -> SEND <$> _smpP <*> (unTail <$> _smpP) PING_ -> pure PING - PFWD_ -> error "TODO: PFWD_" - RFWD_ -> error "TODO: RFWD_" - PRXY_ -> PRXY <$> (_smpP >>= either fail pure . strDecode) <*> _smpP + RFWD_ -> RFWD <$> (EncFwdTransmission . unTail <$> _smpP) + CT SProxiedClient tag -> + Cmd SProxiedClient <$> case tag of + PFWD_ -> PFWD <$> _smpP <*> (EncTransmission . unTail <$> smpP) + PRXY_ -> PRXY <$> _smpP <*> smpP CT SNotifier NSUB_ -> pure $ Cmd SNotifier NSUB fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg @@ -1273,7 +1353,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where e (MSG_, ' ', msgId, Tail body) NID nId srvNtfDh -> e (NID_, ' ', nId, srvNtfDh) NMSG nmsgNonce encNMsgMeta -> e (NMSG_, ' ', nmsgNonce, encNMsgMeta) - PKEY cert key -> e (PKEY_, ' ', C.encodeCertChain cert, C.SignedObject key) + PKEY sid vr (cert, key) -> e (PKEY_, ' ', sid, vr, C.encodeCertChain cert, C.SignedObject key) RRES (EncFwdResponse encBlock) -> e (RRES_, ' ', Tail encBlock) PRES (EncResponse encBlock) -> e (PRES_, ' ', Tail encBlock) END -> e END_ @@ -1293,7 +1373,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where IDS_ -> IDS <$> (QIK <$> _smpP <*> smpP <*> smpP) NID_ -> NID <$> _smpP <*> smpP NMSG_ -> NMSG <$> _smpP <*> smpP - PKEY_ -> PKEY <$> (A.space *> C.certChainP) <*> (C.getSignedExact <$> smpP) + PKEY_ -> PKEY <$> _smpP <*> smpP <*> ((,) <$> C.certChainP <*> (C.getSignedExact <$> smpP)) RRES_ -> RRES <$> (EncFwdResponse . unTail <$> _smpP) PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP) END_ -> pure END @@ -1308,19 +1388,24 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where PEBlock -> BLOCK {-# INLINE fromProtocolError #-} - checkCredentials (_, _, queueId, _) cmd = case cmd of + checkCredentials (_, _, entId, _) cmd = case cmd of -- IDS response should not have queue ID IDS _ -> Right cmd -- ERR response does not always have queue ID ERR _ -> Right cmd -- PONG response must not have queue ID - PONG - | B.null queueId -> Right cmd - | otherwise -> Left $ CMD HAS_AUTH + PONG -> noEntityMsg + PKEY {} -> noEntityMsg + RRES _ -> noEntityMsg -- other broker responses must have queue ID _ - | B.null queueId -> Left $ CMD NO_ENTITY + | B.null entId -> Left $ CMD NO_ENTITY | otherwise -> Right cmd + where + noEntityMsg :: Either ErrorType BrokerMsg + noEntityMsg + | B.null entId = Right cmd + | otherwise = Left $ CMD HAS_AUTH -- | Parse SMP protocol commands and broker messages parseProtocol :: forall v err msg. ProtocolEncoding v err msg => Version v -> ByteString -> Either err msg @@ -1343,6 +1428,7 @@ instance Encoding ErrorType where BLOCK -> "BLOCK" SESSION -> "SESSION" CMD err -> "CMD " <> smpEncode err + PROXY err -> "PROXY " <> smpEncode err AUTH -> "AUTH" QUOTA -> "QUOTA" EXPIRED -> "EXPIRED" @@ -1356,6 +1442,7 @@ instance Encoding ErrorType where "BLOCK" -> pure BLOCK "SESSION" -> pure SESSION "CMD" -> CMD <$> _smpP + "PROXY" -> PROXY <$> _smpP "AUTH" -> pure AUTH "QUOTA" -> pure QUOTA "EXPIRED" -> pure EXPIRED @@ -1381,7 +1468,29 @@ instance Encoding CommandError where "NO_AUTH" -> pure NO_AUTH "HAS_AUTH" -> pure HAS_AUTH "NO_ENTITY" -> pure NO_ENTITY - "NO_QUEUE" -> pure NO_ENTITY + "NO_QUEUE" -> pure NO_ENTITY -- for backward compatibility + _ -> fail "bad command error type" + +instance Encoding ProxyError where + smpEncode e = case e of + PROTOCOL -> "PROTOCOL" + RESPONSE -> "RESPONSE" + UNEXPECTED -> "UNEXPECTED" + TIMEOUT -> "TIMEOUT" + NETWORK -> "NETWORK" + BAD_HOST -> "BAD_HOST" + NO_SESSION -> "NO_SESSION" + TRANSPORT -> "TRANSPORT" + smpP = + A.takeTill (== ' ') >>= \case + "PROTOCOL" -> pure PROTOCOL + "RESPONSE" -> pure RESPONSE + "UNEXPECTED" -> pure UNEXPECTED + "TIMEOUT" -> pure TIMEOUT + "NETWORK" -> pure NETWORK + "BAD_HOST" -> pure BAD_HOST + "NO_SESSION" -> pure NO_SESSION + "TRANSPORT" -> pure TRANSPORT _ -> fail "bad command error type" -- | Send signed SMP transmission to TCP transport. @@ -1521,4 +1630,6 @@ $(J.deriveJSON defaultJSON ''MsgFlags) $(J.deriveJSON (sumTypeJSON id) ''CommandError) +$(J.deriveJSON (sumTypeJSON id) ''ProxyError) + $(J.deriveJSON (sumTypeJSON id) ''ErrorType) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 2ff0d5e51..0991cd2a8 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -13,7 +13,6 @@ {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} -{-# LANGUAGE TypeApplications #-} -- | -- Module : Simplex.Messaging.Server @@ -43,6 +42,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Control.Monad.Trans.Except import Crypto.Random import Data.Bifunctor (first) import Data.ByteString.Base64 (encode) @@ -54,6 +54,7 @@ import Data.Functor (($>)) import Data.Int (Int64) import qualified Data.IntMap.Strict as IM import Data.List (intercalate) +import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M import Data.Maybe (isNothing) @@ -67,8 +68,10 @@ import GHC.Stats (getRTSStats) import GHC.TypeLits (KnownNat) import Network.Socket (ServiceName, Socket, socketToHandle) import Simplex.Messaging.Agent.Lock +import Simplex.Messaging.Client (ProtocolClient (thParams), forwardSMPMessage, smpProxyError) +import Simplex.Messaging.Client.Agent (SMPClientAgent (..), SMPClientAgentEvent (..), getSMPServerClient', lookupSMPServerClient) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Encoding (Encoding (smpEncode)) +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol import Simplex.Messaging.Server.Control @@ -90,6 +93,7 @@ import System.Exit (exitFailure) import System.IO (hPrint, hPutStrLn, hSetNewlineMode, universalNewlineMode) import System.Mem.Weak (deRefWeak) import UnliftIO (timeout) +import UnliftIO.Async (mapConcurrently) import UnliftIO.Concurrent import UnliftIO.Directory (doesFileExist, renameFile) import UnliftIO.Exception @@ -122,11 +126,13 @@ type M a = ReaderT Env IO a smpServer :: TMVar Bool -> ServerConfig -> M () smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do s <- asks server + pa <- asks proxyAgent expired <- restoreServerMessages restoreServerStats expired raceAny_ ( serverThread s "server subscribedQ" subscribedQ subscribers subscriptions cancelSub : serverThread s "server ntfSubscribedQ" ntfSubscribedQ Env.notifiers ntfSubscriptions (\_ -> pure ()) + : receiveFromProxyAgent pa : map runServer transports <> expireMessagesThread_ cfg <> serverStatsThread_ cfg <> controlPortThread_ cfg ) `finally` withLock' (savingLock s) "final" (saveServer False) @@ -179,6 +185,19 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do mkWeakThreadId t >>= atomically . modifyTVar' (endThreads c) . IM.insert tId atomically $ TM.lookupDelete qId (clientSubs c) + receiveFromProxyAgent :: ProxyAgent -> M () + receiveFromProxyAgent ProxyAgent {smpAgent = SMPClientAgent {agentQ}} = + forever $ + atomically (readTBQueue agentQ) >>= \case + CAConnected srv -> logInfo $ "SMP server connected " <> showServer' srv + CADisconnected srv [] -> logInfo $ "SMP server disconnected " <> showServer' srv + CADisconnected srv subs -> logError $ "SMP server disconnected " <> showServer' srv <> " / subscriptions: " <> tshow (length subs) + CAReconnected srv -> logInfo $ "SMP server reconnected " <> showServer' srv + CAResubscribed srv subs -> logError $ "SMP server resubscribed " <> showServer' srv <> " / subscriptions: " <> tshow (length subs) + CASubError srv errs -> logError $ "SMP server subscription errors " <> showServer' srv <> " / errors: " <> tshow (length errs) + where + showServer' = decodeLatin1 . strEncode . host + expireMessagesThread_ :: ServerConfig -> [M ()] expireMessagesThread_ ServerConfig {messageExpiration = Just msgExp} = [expireMessages msgExp] expireMessagesThread_ _ = [] @@ -314,7 +333,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do CPResume -> withAdminRole $ hPutStrLn h "resume not implemented" CPClients -> withAdminRole $ do active <- unliftIO u (asks clients) >>= readTVarIO - hPutStrLn h $ "clientId,sessionId,connected,createdAt,rcvActiveAt,sndActiveAt,age,subscriptions" + hPutStrLn h "clientId,sessionId,connected,createdAt,rcvActiveAt,sndActiveAt,age,subscriptions" forM_ (IM.toList active) $ \(cid, Client {sessionId, connected, createdAt, rcvActiveAt, sndActiveAt, subscriptions}) -> do connected' <- bshow <$> readTVarIO connected rcvActiveAt' <- strEncode <$> readTVarIO rcvActiveAt @@ -410,7 +429,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do hPutStrLn h "AUTH" runClientTransport :: Transport c => THandleSMP c 'TServer -> M () -runClientTransport th@THandle {params = THandleParams {thVersion, sessionId}} = do +runClientTransport th@THandle {params = thParams@THandleParams {thVersion, sessionId}} = do q <- asks $ tbqSize . config ts <- liftIO getSystemTime active <- asks clients @@ -422,7 +441,7 @@ runClientTransport th@THandle {params = THandleParams {thVersion, sessionId}} = s <- asks server expCfg <- asks $ inactiveClientExpiration . config labelMyThread . B.unpack $ "client $" <> encode sessionId - raceAny_ ([liftIO $ send th c, client c s, receive th c] <> disconnectThread_ c expCfg) + raceAny_ ([liftIO $ send th c, client thParams c s, receive th c] <> disconnectThread_ c expCfg) `finally` clientDisconnected c where disconnectThread_ c (Just expCfg) = [liftIO $ disconnectTransport th (rcvActiveAt c) (sndActiveAt c) expCfg (noSubscriptions c)] @@ -463,19 +482,19 @@ receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActi forever $ do ts <- L.toList <$> liftIO (tGet th) atomically . writeTVar rcvActiveAt =<< liftIO getSystemTime - as <- partitionEithers <$> mapM cmdAction ts - write sndQ $ fst as - write rcvQ $ snd as + (errs, cmds) <- partitionEithers <$> mapM cmdAction ts + write sndQ errs + write rcvQ cmds where cmdAction :: SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) - cmdAction (tAuth, authorized, (corrId, queueId, cmdOrError)) = + cmdAction (tAuth, authorized, (corrId, entId, cmdOrError)) = case cmdOrError of - Left e -> pure $ Left (corrId, queueId, ERR e) - Right cmd -> verified <$> verifyTransmission ((,C.cbNonce (bs corrId)) <$> thAuth) tAuth authorized queueId cmd + Left e -> pure $ Left (corrId, entId, ERR e) + Right cmd -> verified <$> verifyTransmission ((,C.cbNonce (bs corrId)) <$> thAuth) tAuth authorized entId cmd where verified = \case - VRVerified qr -> Right (qr, (corrId, queueId, cmd)) - VRFailed -> Left (corrId, queueId, ERR AUTH) + VRVerified qr -> Right (qr, (corrId, entId, cmd)) + VRFailed -> Left (corrId, entId, ERR AUTH) write q = mapM_ (atomically . writeTBQueue q) . L.nonEmpty send :: Transport c => THandleSMP c 'TServer -> Client -> IO () @@ -522,19 +541,18 @@ verifyTransmission auth_ tAuth authorized queueId cmd = -- SEND will be accepted without authorization before the queue is secured with KEY command Cmd SSender SEND {} -> verifyQueue (\q -> Just q `verified` maybe (isNothing tAuth) verify (senderKey q)) <$> get SSender Cmd SSender PING -> pure $ VRVerified Nothing - -- NSUB will not be accepted without authorization - Cmd SNotifier NSUB -> verifyQueue (\q -> maybe dummyVerify (Just q `verifiedWith`) (notifierKey <$> notifier q)) <$> get SNotifier - Cmd SSender PRXY {} -> pure $ VRVerified Nothing - Cmd SSender PFWD {} -> pure $ VRVerified Nothing Cmd SSender RFWD {} -> pure $ VRVerified Nothing + -- NSUB will not be accepted without authorization + Cmd SNotifier NSUB -> verifyQueue (\q -> maybe dummyVerify (\n -> Just q `verifiedWith` notifierKey n) (notifier q)) <$> get SNotifier + Cmd SProxiedClient _ -> pure $ VRVerified Nothing where verify = verifyCmdAuthorization auth_ tAuth authorized dummyVerify = verify (dummyAuthKey tAuth) `seq` VRFailed verifyQueue :: (QueueRec -> VerificationResult) -> Either ErrorType QueueRec -> VerificationResult - verifyQueue = either (\_ -> dummyVerify) + verifyQueue = either (const dummyVerify) verified q cond = if cond then VRVerified q else VRFailed verifiedWith q k = q `verified` verify k - get :: SParty p -> M (Either ErrorType QueueRec) + get :: DirectParty p => SParty p -> M (Either ErrorType QueueRec) get party = do st <- asks queueStore atomically $ getQueue st party queueId @@ -584,36 +602,55 @@ dummyKeyEd448 = "MEMwBQYDK2VxAzoA6ibQc9XpkSLtwrf7PLvp81qW/etiumckVFImCMRdftcG/Xo dummyKeyX25519 :: C.PublicKey 'C.X25519 dummyKeyX25519 = "MCowBQYDK2VuAyEA4JGSMYht18H4mas/jHeBwfcM7jLwNYJNOAhi2/g4RXg=" -client :: Client -> Server -> M () -client clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Server {subscribedQ, ntfSubscribedQ, notifiers} = do +client :: THandleParams SMPVersion 'TServer -> Client -> Server -> M () +client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Server {subscribedQ, ntfSubscribedQ, notifiers} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " commands" - forever $ - atomically (readTBQueue rcvQ) - >>= mapM processCommand - >>= atomically . writeTBQueue sndQ + forever $ do + (proxied, rs) <- partitionEithers . L.toList <$> (mapM processCommand =<< atomically (readTBQueue rcvQ)) + forM_ (L.nonEmpty rs) reply + -- TODO cancel this thread if the client gets disconnected + -- TODO limit client concurrency + forM_ (L.nonEmpty proxied) $ \cmds -> forkIO $ mapConcurrently processProxiedCmd cmds >>= reply where - processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Transmission BrokerMsg) + reply :: MonadIO m => NonEmpty (Transmission BrokerMsg) -> m () + reply = atomically . writeTBQueue sndQ + processProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (Transmission BrokerMsg) + processProxiedCmd (corrId, sessId, command) = (corrId, sessId,) <$> case command of + PRXY srv auth -> ifM allowProxy getRelay (pure $ ERR AUTH) + where + allowProxy = do + ServerConfig {allowSMPProxy, newQueueBasicAuth} <- asks config + pure $ allowSMPProxy && maybe True ((== auth) . Just) newQueueBasicAuth + getRelay = do + ProxyAgent {smpAgent} <- asks proxyAgent + -- TODO catch IO errors too + liftIO $ proxyResp <$> runExceptT (getSMPServerClient' smpAgent srv) + where + proxyResp = \case + Right smp -> + let THandleParams {sessionId = srvSessId, thAuth} = thParams smp + vr = supportedServerSMPRelayVRange + in case thAuth of + Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey + Nothing -> ERR $ PROXY TRANSPORT -- TODO different error? + Left err -> ERR $ smpProxyError err + PFWD pubKey encBlock -> do + ProxyAgent {smpAgent} <- asks proxyAgent + atomically (lookupSMPServerClient smpAgent sessId) >>= \case + Just smp -> liftIO $ either (ERR . smpProxyError) PRES <$> runExceptT (forwardSMPMessage smp corrId pubKey encBlock) + Nothing -> pure $ ERR $ PROXY NO_SESSION + processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Either (Transmission (Command 'ProxiedClient)) (Transmission BrokerMsg)) processCommand (qr_, (corrId, queueId, cmd)) = do st <- asks queueStore case cmd of - Cmd SSender command -> - case command of - SEND flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody - PING -> pure (corrId, "", PONG) - PRXY relay auth -> - ifM - allowProxy - (setupProxy relay) - (pure (corrId, queueId, ERR AUTH)) - where - allowProxy = do - ServerConfig {allowSMPProxy, newQueueBasicAuth} <- asks config - pure $ allowSMPProxy && maybe True ((== auth) . Just) newQueueBasicAuth - PFWD _dhPub _encBlock -> error "TODO: processCommand.PFWD" - RFWD _encBlock -> error "TODO: processCommand.RFWD" - Cmd SNotifier NSUB -> subscribeNotifications + Cmd SProxiedClient command -> pure $ Left (corrId, queueId, command) + Cmd SSender command -> Right <$> case command of + SEND flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody + PING -> pure (corrId, "", PONG) + RFWD encBlock -> (corrId, "",) <$> processForwardedCommand encBlock + Cmd SNotifier NSUB -> Right <$> subscribeNotifications Cmd SRecipient command -> - case command of + Right <$> case command of NEW rKey dhKey auth subMode -> ifM allowNew @@ -877,6 +914,59 @@ client clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Serv encNMsgMeta = C.cbEncrypt rcvNtfDhSecret cbNonce (smpEncode msgMeta) 128 pure . (cbNonce,) $ fromRight "" encNMsgMeta + processForwardedCommand :: EncFwdTransmission -> M BrokerMsg + processForwardedCommand (EncFwdTransmission s) = fmap (either id id) . runExceptT $ do + -- TODO error + THAuthServer {clientPeerPubKey, serverPrivKey} <- maybe (throwError $ ERR INTERNAL) pure thAuth + -- TODO compute during handshake? + let sessSecret = C.dh' clientPeerPubKey serverPrivKey + proxyNonce = C.cbNonce $ bs corrId + -- TODO error + s' <- liftEitherWith internalErr $ C.cbDecrypt sessSecret proxyNonce s + -- TODO error + FwdTransmission {fwdCorrId, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith internalErr $ smpDecode s' + -- TODO error - this error is reported to proxy, as we failed to get to client's transmission + let clientSecret = C.dh' fwdKey serverPrivKey + clientNonce = C.cbNonce $ bs fwdCorrId + b <- liftEitherWith internalErr $ C.cbDecrypt clientSecret clientNonce et + -- only allowing single forwarded transactions + let t' = tDecodeParseValidate thParams' $ L.head $ tParse thParams' b + clntThAuth = Just $ THAuthServer {clientPeerPubKey = fwdKey, serverPrivKey} + -- TODO error + r <- + lift (rejectOrVerify clntThAuth t') >>= \case + Left r -> pure r + Right t''@(_, (corrId', entId', _)) -> + -- Left will not be returned by processCommand, as only SEND command is allowed + fromRight (corrId', entId', ERR INTERNAL) <$> lift (processCommand t'') + + -- encode response + r' <- case batchTransmissions (batch thParams') (blockSize thParams') [Right (Nothing, encodeTransmission thParams' r)] of + [] -> throwE $ ERR INTERNAL -- TODO error + TBError _ _ : _ -> throwE $ ERR INTERNAL -- TODO error + TBTransmission b' _ : _ -> pure b' + TBTransmissions b' _ _ : _ -> pure b' + -- encrypt to client + r2 <- liftEitherWith internalErr $ EncResponse <$> C.cbEncrypt clientSecret (C.reverseNonce clientNonce) r' paddedProxiedMsgLength + -- encrypt to proxy + let fr = FwdResponse {fwdCorrId, fwdResponse = r2} + r3 <- liftEitherWith internalErr $ EncFwdResponse <$> C.cbEncrypt sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) paddedForwardedMsgLength + pure $ RRES r3 + where + internalErr _ = ERR INTERNAL -- TODO errors + THandleParams {thAuth} = thParams' + rejectOrVerify :: Maybe (THandleAuth 'TServer) -> SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) + rejectOrVerify clntThAuth (tAuth, authorized, (corrId', entId', cmdOrError)) = + case cmdOrError of + Left e -> pure $ Left (corrId', entId', ERR e) + -- flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody + Right cmd'@(Cmd SSender SEND {}) -> verified <$> verifyTransmission ((,C.cbNonce (bs corrId')) <$> clntThAuth) tAuth authorized entId' cmd' + where + verified = \case + VRVerified qr -> Right (qr, (corrId', entId', cmd')) + VRFailed -> Left (corrId', entId', ERR AUTH) + Right _ -> pure $ Left (corrId', entId', ERR $ CMD PROHIBITED) + deliverMessage :: T.Text -> QueueRec -> RecipientId -> TVar Sub -> MsgQueue -> Maybe Message -> M (Transmission BrokerMsg) deliverMessage name qr rId sub q msg_ = time (name <> " deliver") $ do readTVarIO sub >>= \case @@ -936,15 +1026,6 @@ client clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Serv Right q -> updateDeletedStats q $> ok Left e -> pure $ err e - setupProxy :: SMPServer -> M (Transmission BrokerMsg) - setupProxy todo'relay = undefined - -- do - -- let relaySessionId = "TODO: relaySessionId" - -- (dummyRelayDhPublic, _) <- atomically . C.generateKeyPair =<< asks random - -- (_, dummySignKey) <- atomically . C.generateKeyPair =<< asks random - -- let dummyRelayKeySignature = C.sign' dummySignKey $ smpEncode dummyRelayDhPublic - -- pure (corrId, relaySessionId, PKEY dummyRelayDhPublic dummyRelayKeySignature) - ok :: Transmission BrokerMsg ok = (corrId, queueId, OK) diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index 74d7d96e3..11ea4fd07 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -15,15 +15,14 @@ import qualified Data.IntMap.Strict as IM import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Text (Text) import Data.Time.Clock (getCurrentTime) import Data.Time.Clock.System (SystemTime) import Data.X509.Validation (Fingerprint (..)) import Network.Socket (ServiceName) import qualified Network.TLS as T import Numeric.Natural -import Simplex.Messaging.Agent.Env.SQLite (Worker) import Simplex.Messaging.Agent.Lock +import Simplex.Messaging.Client.Agent (SMPClientAgent, SMPClientAgentConfig, newSMPClientAgent) import Simplex.Messaging.Crypto (KeyHash (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol @@ -35,7 +34,7 @@ import Simplex.Messaging.Server.Stats import Simplex.Messaging.Server.StoreLog import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport, SessionId, VersionSMP, VersionRangeSMP) +import Simplex.Messaging.Transport (ATransport, VersionRangeSMP, VersionSMP) import Simplex.Messaging.Transport.Server (SocketState, TransportServerConfig, loadFingerprint, loadTLSServerParams, newSocketState) import System.IO (IOMode (..)) import System.Mem.Weak (Weak) @@ -82,6 +81,7 @@ data ServerConfig = ServerConfig transportConfig :: TransportServerConfig, -- | run listener on control port controlPort :: Maybe ServiceName, + smpAgentCfg :: SMPClientAgentConfig, allowSMPProxy :: Bool -- auth is the same with `newQueueBasicAuth` } @@ -115,7 +115,7 @@ data Env = Env sockets :: SocketState, clientSeq :: TVar ClientId, clients :: TVar (IntMap Client), - proxyServer :: SMPProxyServer -- senders served on this proxy + proxyAgent :: ProxyAgent -- senders served on this proxy } data Server = Server @@ -126,16 +126,8 @@ data Server = Server savingLock :: Lock } -data SMPProxyServer = SMPProxyServer - { relaySessions :: TMap SessionId SMPProxiedRelay, - relayServers :: TMap Text SessionId -- speed up client lookups by server URI - } - -data SMPProxiedRelay = SMPProxiedRelay - { worker :: Worker, - proxyKey :: C.DhSecretX25519, - fwdQ :: TBQueue (ClientId, CorrId, C.PublicKeyX25519, ByteString) -- FWD args from multiple clients using this server - -- can be used for QUOTA retries until the session is gone +data ProxyAgent = ProxyAgent + { smpAgent :: SMPClientAgent } type ClientId = Int @@ -194,7 +186,7 @@ newSubscription subThread = do return Sub {subThread, delivered} newEnv :: ServerConfig -> IO Env -newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, storeLogFile} = do +newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, storeLogFile, smpAgentCfg} = do server <- atomically newServer queueStore <- atomically newQueueStore msgStore <- atomically newMsgStore @@ -207,8 +199,8 @@ newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, sockets <- atomically newSocketState clientSeq <- newTVarIO 0 clients <- newTVarIO mempty - proxyServer <- newSMPProxyServer - return Env {config, server, serverIdentity, queueStore, msgStore, random, storeLog, tlsServerParams, serverStats, sockets, clientSeq, clients, proxyServer} + proxyAgent <- atomically $ newSMPProxyAgent smpAgentCfg random + return Env {config, server, serverIdentity, queueStore, msgStore, random, storeLog, tlsServerParams, serverStats, sockets, clientSeq, clients, proxyAgent} where restoreQueues :: QueueStore -> FilePath -> IO (StoreLog 'WriteMode) restoreQueues QueueStore {queues, senders, notifiers} f = do @@ -225,8 +217,7 @@ newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, Nothing -> id Just NtfCreds {notifierId} -> M.insert notifierId (recipientId q) -newSMPProxyServer :: MonadIO m => m SMPProxyServer -newSMPProxyServer = do - relayServers <- atomically TM.empty - relaySessions <- atomically TM.empty - pure SMPProxyServer {relayServers, relaySessions} +newSMPProxyAgent :: SMPClientAgentConfig -> TVar ChaChaDRG -> STM ProxyAgent +newSMPProxyAgent smpAgentCfg random = do + smpAgent <- newSMPClientAgent smpAgentCfg random + pure ProxyAgent {smpAgent} diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index d14bdac1f..67064f1c7 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -18,6 +18,8 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Network.Socket (HostName) import Options.Applicative +import Simplex.Messaging.Client (ProtocolClientConfig (..)) +import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (ProtoServerWithAuth), pattern SMPServer) @@ -25,10 +27,11 @@ import Simplex.Messaging.Server (runSMPServer) import Simplex.Messaging.Server.CLI import Simplex.Messaging.Server.Env.STM (ServerConfig (..), defMsgExpirationDays, defaultInactiveClientExpiration, defaultMessageExpiration) import Simplex.Messaging.Server.Expiration -import Simplex.Messaging.Transport (simplexMQVersion, supportedServerSMPRelayVRange) +import Simplex.Messaging.Transport (simplexMQVersion, supportedServerSMPRelayVRange, batchCmdsSMPVersion, sendingProxySMPVersion) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig (..), defaultTransportServerConfig) import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Messaging.Version (mkVersionRange) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath (combine) import System.IO (BufferMode (..), hSetBuffering, stderr, stdout) @@ -214,6 +217,7 @@ smpServerCLI cfgPath logPath = { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini }, controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini, + smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion}}, allowSMPProxy = True -- TODO: "get from INI" } @@ -306,4 +310,3 @@ cliCommandP cfgPath logPath iniFile = pure InitOptions {enableStoreLog, logStats, signAlgorithm, ip, fqdn, password, scripted} parseBasicAuth :: ReadM ServerPassword parseBasicAuth = eitherReader $ fmap ServerPassword . strDecode . B.pack - diff --git a/src/Simplex/Messaging/Server/QueueStore/STM.hs b/src/Simplex/Messaging/Server/QueueStore/STM.hs index b76ad4998..8de7a38c6 100644 --- a/src/Simplex/Messaging/Server/QueueStore/STM.hs +++ b/src/Simplex/Messaging/Server/QueueStore/STM.hs @@ -54,7 +54,7 @@ addQueue QueueStore {queues, senders} q@QueueRec {recipientId = rId, senderId = where hasId = (||) <$> TM.member rId queues <*> TM.member sId senders -getQueue :: QueueStore -> SParty p -> QueueId -> STM (Either ErrorType QueueRec) +getQueue :: DirectParty p => QueueStore -> SParty p -> QueueId -> STM (Either ErrorType QueueRec) getQueue QueueStore {queues, senders, notifiers} party qId = toResult <$> (mapM readTVar =<< getVar) where diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 565199c36..33aae5c60 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -115,6 +115,11 @@ import UnliftIO.STM -- * Transport parameters +-- min size it works with: +-- unsigned message: 16292 (paddedProxiedMsgLength = 16151, paddedForwardedMsgLength = 16239) +-- Ed448: 16406 (16384 + 22, fails with 21) +-- Ed25519: 16356 +-- X25519: 16381 smpBlockSize :: Int smpBlockSize = 16384 @@ -358,6 +363,7 @@ data ServerHandshake = ServerHandshake { smpVersionRange :: VersionRangeSMP, sessionId :: SessionId, -- pub key to agree shared secrets for command authorization and entity ID encryption. + -- todo C.PublicKeyX25519 authPubKey :: Maybe (X.CertificateChain, X.SignedExact X.PubKey) } diff --git a/tests/CoreTests/BatchingTests.hs b/tests/CoreTests/BatchingTests.hs index c349096dc..3ca78a8c4 100644 --- a/tests/CoreTests/BatchingTests.hs +++ b/tests/CoreTests/BatchingTests.hs @@ -281,12 +281,12 @@ randomSUB_ :: (C.AlgorithmI a, C.AuthAlgorithm a) => C.SAlgorithm a -> VersionSM randomSUB_ a v sessId = do g <- C.newRandom rId <- atomically $ C.randomBytes 24 g - corrId <- atomically $ CorrId <$> C.randomBytes 24 g + nonce@(C.CbNonce corrId) <- atomically $ C.randomCbNonce g (rKey, rpKey) <- atomically $ C.generateAuthKeyPair a g thAuth_ <- testTHandleAuth v g rKey let thParams = testTHandleParams v sessId - TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, rId, Cmd SRecipient SUB) - pure $ (,tToSend) <$> authTransmission thAuth_ (Just rpKey) corrId tForAuth + TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (CorrId corrId, rId, Cmd SRecipient SUB) + pure $ (,tToSend) <$> authTransmission thAuth_ (Just rpKey) nonce tForAuth randomSUBCmd :: ProtocolClient SMPVersion ErrorType BrokerMsg -> IO (PCTransmission ErrorType BrokerMsg) randomSUBCmd = randomSUBCmd_ C.SEd25519 @@ -311,13 +311,13 @@ randomSEND_ :: (C.AlgorithmI a, C.AuthAlgorithm a) => C.SAlgorithm a -> VersionS randomSEND_ a v sessId len = do g <- C.newRandom sId <- atomically $ C.randomBytes 24 g - corrId <- atomically $ CorrId <$> C.randomBytes 3 g + nonce@(C.CbNonce corrId) <- atomically $ C.randomCbNonce g (sKey, spKey) <- atomically $ C.generateAuthKeyPair a g thAuth_ <- testTHandleAuth v g sKey msg <- atomically $ C.randomBytes len g let thParams = testTHandleParams v sessId - TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, sId, Cmd SSender $ SEND noMsgFlags msg) - pure $ (,tToSend) <$> authTransmission thAuth_ (Just spKey) corrId tForAuth + TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (CorrId corrId, sId, Cmd SSender $ SEND noMsgFlags msg) + pure $ (,tToSend) <$> authTransmission thAuth_ (Just spKey) nonce tForAuth testTHandleParams :: VersionSMP -> ByteString -> THandleParams SMPVersion 'TClient testTHandleParams v sessionId = diff --git a/tests/CoreTests/ProtocolErrorTests.hs b/tests/CoreTests/ProtocolErrorTests.hs index 7b1a7b813..a486e6549 100644 --- a/tests/CoreTests/ProtocolErrorTests.hs +++ b/tests/CoreTests/ProtocolErrorTests.hs @@ -2,7 +2,6 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} -{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} module CoreTests.ProtocolErrorTests where @@ -14,9 +13,10 @@ import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import Simplex.FileTransfer.Transport (XFTPErrorType (..)) import Simplex.Messaging.Agent.Protocol +import qualified Simplex.Messaging.Agent.Protocol as Agent import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (CommandError (..), ErrorType (..)) +import Simplex.Messaging.Protocol (CommandError (..), ErrorType (..), ProxyError (..)) import Simplex.Messaging.Transport (HandshakeError (..), TransportError (..)) import Simplex.RemoteControl.Types (RCErrorType (..)) import Test.Hspec @@ -33,7 +33,7 @@ protocolErrorTests = modifyMaxSuccess (const 1000) $ do || strDecode (strEncode err) == Right err where errHasSpaces = \case - BROKER srv (RESPONSE e) -> hasSpaces srv || hasSpaces e + BROKER srv (Agent.RESPONSE e) -> hasSpaces srv || hasSpaces e BROKER srv _ -> hasSpaces srv _ -> False hasSpaces s = ' ' `B.elem` encodeUtf8 (T.pack s) @@ -54,6 +54,8 @@ deriving instance Generic ErrorType deriving instance Generic CommandError +deriving instance Generic ProxyError + deriving instance Generic TransportError deriving instance Generic HandshakeError @@ -78,6 +80,8 @@ instance Arbitrary ErrorType where arbitrary = genericArbitraryU instance Arbitrary CommandError where arbitrary = genericArbitraryU +instance Arbitrary ProxyError where arbitrary = genericArbitraryU + instance Arbitrary TransportError where arbitrary = genericArbitraryU instance Arbitrary HandshakeError where arbitrary = genericArbitraryU diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index cbdf4319d..df2db2ae1 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -16,7 +16,8 @@ import Control.Monad.Except (runExceptT) import Data.ByteString.Char8 (ByteString) import Data.List.NonEmpty (NonEmpty) import Network.Socket -import Simplex.Messaging.Client (chooseTransportHost, defaultNetworkConfig) +import Simplex.Messaging.Client (ProtocolClientConfig (..), chooseTransportHost, defaultNetworkConfig) +import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Protocol @@ -112,6 +113,7 @@ cfg = smpServerVRange = supportedServerSMPRelayVRange, transportConfig = defaultTransportServerConfig, controlPort = Nothing, + smpAgentCfg = defaultSMPClientAgentConfig, allowSMPProxy = False } @@ -119,7 +121,12 @@ cfgV7 :: ServerConfig cfgV7 = cfg {smpServerVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} proxyCfg :: ServerConfig -proxyCfg = cfg {allowSMPProxy = True} +proxyCfg = + cfgV7 + { allowSMPProxy = True, + smpServerVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, + smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion}} + } withSmpServerStoreMsgLogOn :: HasCallStack => ATransport -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a withSmpServerStoreMsgLogOn t = withSmpServerConfigOn t cfg {storeLogFile = Just testStoreLogFile, storeMsgsFile = Just testStoreMsgsFile, serverStatsBackupFile = Just testServerStatsBackupFile} diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 6cdf1f590..1ab2779c8 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -1,38 +1,117 @@ {-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} module SMPProxyTests where -import Debug.Trace +import AgentTests.FunctionalAPITests (runRight_) +import Data.ByteString.Char8 (ByteString) import SMPAgentClient (testSMPServer, testSMPServer2) import SMPClient -import ServerTests (sendRecv) +import qualified SMPClient as SMP +import ServerTests (decryptMsgV3, sendRecv) +import Simplex.Messaging.Client +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Version (mkVersionRange) import Test.Hspec +import UnliftIO smpProxyTests :: Spec smpProxyTests = do describe "server configuration" $ do it "refuses proxy handshake unless enabled" testNoProxy it "checks basic auth in proxy requests" testProxyAuth - xdescribe "proxy requests" $ do - xdescribe "bad relay URIs" $ do - it "host not resolved" todo - it "when SMP port blackholed" todo - it "no SMP service at host/port" todo - it "bad SMP fingerprint" todo - it "connects to relay" testProxyConnect - xit "connects to itself as a relay" todo + describe "proxy requests" $ do + describe "bad relay URIs" $ do + xit "host not resolved" todo + xit "when SMP port blackholed" todo + xit "no SMP service at host/port" todo + xit "bad SMP fingerprint" todo xit "batching proxy requests" todo - xdescribe "forwarding requests" $ do - it "sender-proxy-relay-recipient works" todo - it "similar timing for proxied and direct sends" todo + describe "forwarding requests" $ do + describe "deliver message via SMP proxy" $ do + it "same server" $ + withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> do + let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash + let relayServ = proxyServ + deliverMessageViaProxy proxyServ relayServ C.SEd448 "hello 1" "hello 2" + it "different servers" $ + withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> + withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do + let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash + let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash + deliverMessageViaProxy proxyServ relayServ C.SEd448 "hello 1" "hello 2" + xit "max message size, Ed448 keys" $ + withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> + withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do + g <- C.newRandom + msg <- atomically $ C.randomBytes maxMessageLength g + msg' <- atomically $ C.randomBytes maxMessageLength g + let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash + let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash + deliverMessageViaProxy proxyServ relayServ C.SEd448 msg msg' + it "max message size, Ed25519 keys" $ + withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> + withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do + g <- C.newRandom + msg <- atomically $ C.randomBytes maxMessageLength g + msg' <- atomically $ C.randomBytes maxMessageLength g + let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash + let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash + deliverMessageViaProxy proxyServ relayServ C.SEd25519 msg msg' + it "max message size, X25519 keys" $ + withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> + withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do + g <- C.newRandom + msg <- atomically $ C.randomBytes maxMessageLength g + msg' <- atomically $ C.randomBytes maxMessageLength g + let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash + let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash + deliverMessageViaProxy proxyServ relayServ C.SX25519 msg msg' + xit "sender-proxy-relay-recipient works" todo + xit "similar timing for proxied and direct sends" todo + +deliverMessageViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => SMPServer -> SMPServer -> C.SAlgorithm a -> ByteString -> ByteString -> IO () +deliverMessageViaProxy proxyServ relayServ alg msg msg' = do + g <- C.newRandom + -- set up proxy + Right pc <- getProtocolClient g (1, proxyServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} Nothing (\_ -> pure ()) + THAuthClient {} <- maybe (fail "getProtocolClient returned no thAuth") pure $ thAuth $ thParams pc + -- set up relay + msgQ <- newTBQueueIO 4 + Right rc <- getProtocolClient g (2, relayServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} (Just msgQ) (\_ -> pure ()) + runRight_ $ do + -- prepare receiving queue + (rPub, rPriv) <- atomically $ C.generateAuthKeyPair alg g + (rdhPub, rdhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g + QIK {rcvId, sndId, rcvPublicDhKey = srvDh} <- createSMPQueue rc (rPub, rPriv) rdhPub (Just "correct") SMSubscribe + let dec = decryptMsgV3 $ C.dh' srvDh rdhPriv + -- get proxy session + (sessId, v, relayKey) <- createSMPProxySession pc relayServ (Just "correct") + -- send via proxy to unsecured queue + proxySMPMessage pc sessId v relayKey Nothing sndId noMsgFlags msg + -- receive 1 + (_tSess, _v, _sid, _ety, MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody}) <- atomically $ readTBQueue msgQ + liftIO $ dec msgId encBody `shouldBe` Right msg + ackSMPMessage rc rPriv rcvId msgId + -- secure queue + (sPub, sPriv) <- atomically $ C.generateAuthKeyPair alg g + secureSMPQueue rc rPriv rcvId sPub + -- send via proxy to secured queue + proxySMPMessage pc sessId v relayKey (Just sPriv) sndId noMsgFlags msg' + -- receive 2 + (_tSess, _v, _sid, _ety, MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'}) <- atomically $ readTBQueue msgQ + liftIO $ dec msgId' encBody' `shouldBe` Right msg' + ackSMPMessage rc rPriv rcvId msgId' proxyVRange :: VersionRangeSMP proxyVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion @@ -48,21 +127,11 @@ testProxyAuth :: IO () testProxyAuth = do withSmpServerConfigOn (transport @TLS) proxyCfgAuth testPort $ \_ -> do testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do - (_, s, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 $ Just "wrong") - traceShowM s + (_, _s, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 $ Just "wrong") reply `shouldBe` Right (ERR AUTH) where proxyCfgAuth = proxyCfg {newQueueBasicAuth = Just "correct"} -testProxyConnect :: IO () -testProxyConnect = do - withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> do - testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do - (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 Nothing) - case reply of - Right PKEY {} -> pure () - _ -> fail $ "bad reply: " <> show reply - todo :: IO () todo = do fail "TODO" diff --git a/tests/Test.hs b/tests/Test.hs index f9fb2a2c0..cd2b0d8c3 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -47,7 +47,7 @@ main = do $ do describe "Agent SQLite schema dump" schemaDumpTest describe "Core tests" $ do - describe "Batching tests" batchingTests + xdescribe "Batching tests" batchingTests describe "Encoding tests" encodingTests describe "Protocol error tests" protocolErrorTests describe "Version range" versionRangeTests From c00c223f3bb295a62d8507e453bbeac61d102e3a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 18 Apr 2024 22:43:49 +0100 Subject: [PATCH 009/107] remove (or make optional) client key from handshakes (#1104) * remove (or make optional) client key from handshakes * remove comment --- src/Simplex/FileTransfer/Client.hs | 15 +++--- src/Simplex/FileTransfer/Client/Agent.hs | 7 ++- src/Simplex/FileTransfer/Client/Main.hs | 25 +++++----- src/Simplex/FileTransfer/Server.hs | 4 +- src/Simplex/FileTransfer/Transport.hs | 19 ++++---- src/Simplex/Messaging/Agent/Client.hs | 5 +- src/Simplex/Messaging/Client.hs | 13 ++---- .../Messaging/Notifications/Protocol.hs | 2 +- .../Messaging/Notifications/Server/Main.hs | 14 ++---- .../Messaging/Notifications/Transport.hs | 46 +++++++------------ src/Simplex/Messaging/Protocol.hs | 2 +- src/Simplex/Messaging/Transport.hs | 32 ++++++------- tests/CoreTests/BatchingTests.hs | 3 +- tests/NtfClient.hs | 6 +-- tests/SMPClient.hs | 6 +-- tests/XFTPClient.hs | 6 +-- tests/XFTPServerTests.hs | 3 +- 17 files changed, 87 insertions(+), 121 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index a223d492e..6cae2dd59 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -97,8 +97,8 @@ defaultXFTPClientConfig = clientALPN = Just supportedXFTPhandshakes } -getXFTPClient :: TVar ChaChaDRG -> TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) -getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do +getXFTPClient :: TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) +getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do let tcConfig = (transportClientConfig xftpNetworkConfig) {alpn = clientALPN} http2Config = xftpHTTP2Config tcConfig config username = proxyUsername transportSession @@ -112,7 +112,7 @@ getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN thParams0 = THandleParams {sessionId, blockSize = xftpBlockSize, thVersion = VersionXFTP 1, thAuth = Nothing, implySessId = False, batch = True} logDebug $ "Client negotiated handshake protocol: " <> tshow sessionALPN thParams@THandleParams {thVersion} <- case sessionALPN of - Just "xftp/1" -> xftpClientHandshakeV1 g serverVRange keyHash http2Client thParams0 + Just "xftp/1" -> xftpClientHandshakeV1 serverVRange keyHash http2Client thParams0 Nothing -> pure thParams0 _ -> throwError $ PCETransportError (TEHandshake VERSION) logDebug $ "Client negotiated protocol: " <> tshow thVersion @@ -120,13 +120,12 @@ getXFTPClient g transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN atomically $ writeTVar clientVar $ Just c pure c -xftpClientHandshakeV1 :: TVar ChaChaDRG -> VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP 'TClient -> ExceptT XFTPClientError IO (THandleParamsXFTP 'TClient) -xftpClientHandshakeV1 g serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessionId, serverKey} thParams0 = do +xftpClientHandshakeV1 :: VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP 'TClient -> ExceptT XFTPClientError IO (THandleParamsXFTP 'TClient) +xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessionId, serverKey} thParams0 = do shs@XFTPServerHandshake {authPubKey = ck} <- getServerHandshake (v, sk) <- processServerHandshake shs - (k, pk) <- atomically $ C.generateKeyPair g - sendClientHandshake XFTPClientHandshake {xftpVersion = v, keyHash, authPubKey = k} - pure thParams0 {thAuth = Just THAuthClient {serverPeerPubKey = sk, serverCertKey = ck, clientPrivKey = pk}, thVersion = v} + sendClientHandshake XFTPClientHandshake {xftpVersion = v, keyHash} + pure thParams0 {thAuth = Just THAuthClient {serverPeerPubKey = sk, serverCertKey = ck, sessSecret = Nothing}, thVersion = v} where getServerHandshake :: ExceptT XFTPClientError IO XFTPServerHandshake getServerHandshake = do diff --git a/src/Simplex/FileTransfer/Client/Agent.hs b/src/Simplex/FileTransfer/Client/Agent.hs index f86de9afb..1dafc8108 100644 --- a/src/Simplex/FileTransfer/Client/Agent.hs +++ b/src/Simplex/FileTransfer/Client/Agent.hs @@ -11,7 +11,6 @@ import Control.Logger.Simple (logInfo) import Control.Monad import Control.Monad.Except import Control.Monad.Trans (lift) -import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import qualified Data.ByteString.Char8 as B import Data.Text (Text) @@ -61,15 +60,15 @@ newXFTPAgent config = do type ME a = ExceptT XFTPClientAgentError IO a -getXFTPServerClient :: TVar ChaChaDRG -> XFTPClientAgent -> XFTPServer -> ME XFTPClient -getXFTPServerClient g XFTPClientAgent {xftpClients, config} srv = do +getXFTPServerClient :: XFTPClientAgent -> XFTPServer -> ME XFTPClient +getXFTPServerClient XFTPClientAgent {xftpClients, config} srv = do atomically getClientVar >>= either newXFTPClient waitForXFTPClient where connectClient :: ME XFTPClient connectClient = ExceptT $ first (XFTPClientAgentError srv) - <$> getXFTPClient g (1, srv, Nothing) (xftpConfig config) clientDisconnected + <$> getXFTPClient (1, srv, Nothing) (xftpConfig config) clientDisconnected clientDisconnected :: XFTPClient -> IO () clientDisconnected _ = do diff --git a/src/Simplex/FileTransfer/Client/Main.hs b/src/Simplex/FileTransfer/Client/Main.hs index 3320e0a26..b3fa494ed 100644 --- a/src/Simplex/FileTransfer/Client/Main.hs +++ b/src/Simplex/FileTransfer/Client/Main.hs @@ -333,9 +333,9 @@ cliSendFileOpts SendOptions {filePath, outputDir, numRecipients, xftpServers, re rKeys <- atomically $ L.fromList <$> replicateM numRecipients (C.generateAuthKeyPair C.SEd25519 g) digest <- liftIO $ getChunkDigest chunkSpec let ch = FileInfo {sndKey, size = fromIntegral chunkSize, digest} - c <- withRetry retryCount $ getXFTPServerClient g a xftpServer + c <- withRetry retryCount $ getXFTPServerClient a xftpServer (sndId, rIds) <- withRetry retryCount $ createXFTPChunk c spKey ch (L.map fst rKeys) auth - withReconnect g a xftpServer retryCount $ \c' -> uploadXFTPChunk c' spKey sndId chunkSpec + withReconnect a xftpServer retryCount $ \c' -> uploadXFTPChunk c' spKey sndId chunkSpec logInfo $ "uploaded chunk " <> tshow chunkNo uploaded <- atomically . stateTVar uploadedChunks $ \cs -> let cs' = fromIntegral chunkSize : cs in (sum cs', cs') @@ -445,7 +445,7 @@ cliReceiveFile ReceiveOptions {fileDescription, filePath, retryCount, tempPath, when (FileSize encSize /= size) $ throwError $ CLIError "File size mismatch" liftIO $ printNoNewLine "Decrypting file..." CryptoFile path _ <- withExceptT cliCryptoError $ decryptChunks encSize chunkPaths key nonce $ fmap CF.plain . getFilePath - forM_ chunks $ acknowledgeFileChunk g a + forM_ chunks $ acknowledgeFileChunk a whenM (doesPathExist encPath) $ removeDirectoryRecursive encPath liftIO $ do printNoNewLine $ "File downloaded: " <> path @@ -456,7 +456,7 @@ cliReceiveFile ReceiveOptions {fileDescription, filePath, retryCount, tempPath, logInfo $ "downloading chunk " <> tshow chunkNo <> " from " <> showServer server <> "..." chunkPath <- uniqueCombine encPath $ show chunkNo let chunkSpec = XFTPRcvChunkSpec chunkPath (unFileSize chunkSize) (unFileDigest digest) - withReconnect g a server retryCount $ \c -> downloadXFTPChunk g c replicaKey (unChunkReplicaId replicaId) chunkSpec + withReconnect a server retryCount $ \c -> downloadXFTPChunk g c replicaKey (unChunkReplicaId replicaId) chunkSpec logInfo $ "downloaded chunk " <> tshow chunkNo <> " to " <> T.pack chunkPath downloaded <- atomically . stateTVar downloadedChunks $ \cs -> let cs' = fromIntegral (unFileSize chunkSize) : cs in (sum cs', cs') @@ -472,12 +472,12 @@ cliReceiveFile ReceiveOptions {fileDescription, filePath, retryCount, tempPath, ifM (doesDirectoryExist path) (uniqueCombine path name) $ ifM (doesFileExist path) (throwError "File already exists") (pure path) _ -> (`uniqueCombine` name) . ( "Downloads") =<< getHomeDirectory - acknowledgeFileChunk :: TVar ChaChaDRG -> XFTPClientAgent -> FileChunk -> ExceptT CLIError IO () - acknowledgeFileChunk g a FileChunk {replicas = replica : _} = do + acknowledgeFileChunk :: XFTPClientAgent -> FileChunk -> ExceptT CLIError IO () + acknowledgeFileChunk a FileChunk {replicas = replica : _} = do let FileChunkReplica {server, replicaId, replicaKey} = replica - c <- withRetry retryCount $ getXFTPServerClient g a server + c <- withRetry retryCount $ getXFTPServerClient a server withRetry retryCount $ ackXFTPChunk c replicaKey (unChunkReplicaId replicaId) - acknowledgeFileChunk _ _ _ = throwError $ CLIError "chunk has no replicas" + acknowledgeFileChunk _ _ = throwError $ CLIError "chunk has no replicas" printProgress :: String -> Int64 -> Int64 -> IO () printProgress s part total = printNoNewLine $ s <> " " <> show ((part * 100) `div` total) <> "%" @@ -501,8 +501,7 @@ cliDeleteFile DeleteOptions {fileDescription, retryCount, yes} = do deleteFileChunk :: XFTPClientAgent -> FileChunk -> ExceptT CLIError IO () deleteFileChunk a FileChunk {chunkNo, replicas = replica : _} = do let FileChunkReplica {server, replicaId, replicaKey} = replica - g <- liftIO C.newRandom - withReconnect g a server retryCount $ \c -> deleteXFTPChunk c replicaKey (unChunkReplicaId replicaId) + withReconnect a server retryCount $ \c -> deleteXFTPChunk c replicaKey (unChunkReplicaId replicaId) logInfo $ "deleted chunk " <> tshow chunkNo <> " from " <> showServer server deleteFileChunk _ _ = throwError $ CLIError "chunk has no replicas" @@ -570,9 +569,9 @@ prepareChunkSpecs filePath chunkSizes = reverse . snd $ foldl' addSpec (0, []) c getEncPath :: MonadIO m => Maybe FilePath -> String -> m FilePath getEncPath path name = (`uniqueCombine` (name <> ".encrypted")) =<< maybe (liftIO getCanonicalTemporaryDirectory) pure path -withReconnect :: Show e => TVar ChaChaDRG -> XFTPClientAgent -> XFTPServer -> Int -> (XFTPClient -> ExceptT e IO a) -> ExceptT CLIError IO a -withReconnect g a srv n run = withRetry n $ do - c <- withRetry n $ getXFTPServerClient g a srv +withReconnect :: Show e => XFTPClientAgent -> XFTPServer -> Int -> (XFTPClient -> ExceptT e IO a) -> ExceptT CLIError IO a +withReconnect a srv n run = withRetry n $ do + c <- withRetry n $ getXFTPServerClient a srv withExceptT (CLIError . show) (run c) `catchError` \e -> do liftIO $ closeXFTPServerClient a srv throwError e diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index b3bee134f..7b6787a43 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -141,11 +141,11 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira processClientHandshake pk = do unless (B.length bodyHead == xftpBlockSize) $ throwError HANDSHAKE body <- liftHS $ C.unPad bodyHead - XFTPClientHandshake {xftpVersion, keyHash, authPubKey} <- liftHS $ smpDecode body + XFTPClientHandshake {xftpVersion, keyHash} <- liftHS $ smpDecode body kh <- asks serverIdentity unless (keyHash == kh) $ throwError HANDSHAKE unless (xftpVersion `isCompatible` supportedFileServerVRange) $ throwError HANDSHAKE - let auth = THAuthServer {clientPeerPubKey = authPubKey, serverPrivKey = pk} + let auth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} atomically $ TM.insert sessionId (HandshakeAccepted auth xftpVersion) sessions liftIO . sendResponse $ H.responseNoBody N.ok200 [] pure Nothing diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index 041069e98..27f1b8b95 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -10,6 +10,7 @@ module Simplex.FileTransfer.Transport ( supportedFileServerVRange, + authCmdsXFTPVersion, xftpClientHandshakeStub, XFTPClientHandshake (..), -- xftpClientHandshake, @@ -83,14 +84,17 @@ type THandleParamsXFTP p = THandleParams XFTPVersion p initialXFTPVersion :: VersionXFTP initialXFTPVersion = VersionXFTP 1 +authCmdsXFTPVersion :: VersionXFTP +authCmdsXFTPVersion = VersionXFTP 2 + currentXFTPVersion :: VersionXFTP currentXFTPVersion = VersionXFTP 2 supportedFileServerVRange :: VersionRangeXFTP supportedFileServerVRange = mkVersionRange initialXFTPVersion currentXFTPVersion --- XFTP protocol does not support handshake -xftpClientHandshakeStub :: c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c 'TClient) +-- XFTP protocol does not use this handshake method +xftpClientHandshakeStub :: c -> Maybe C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c 'TClient) xftpClientHandshakeStub _c _ks _keyHash _xftpVRange = throwError $ TEHandshake VERSION data XFTPServerHandshake = XFTPServerHandshake @@ -104,19 +108,16 @@ data XFTPClientHandshake = XFTPClientHandshake { -- | agreed XFTP server protocol version xftpVersion :: VersionXFTP, -- | server identity - CA certificate fingerprint - keyHash :: C.KeyHash, - -- | pub key to agree shared secret for entity ID encryption, shared secret for command authorization is agreed using per-queue keys. - authPubKey :: C.PublicKeyX25519 + keyHash :: C.KeyHash } instance Encoding XFTPClientHandshake where - smpEncode XFTPClientHandshake {xftpVersion, keyHash, authPubKey} = - smpEncode (xftpVersion, keyHash, authPubKey) + smpEncode XFTPClientHandshake {xftpVersion, keyHash} = + smpEncode (xftpVersion, keyHash) smpP = do (xftpVersion, keyHash) <- smpP - authPubKey <- smpP Tail _compat <- smpP - pure XFTPClientHandshake {xftpVersion, keyHash, authPubKey} + pure XFTPClientHandshake {xftpVersion, keyHash} instance Encoding XFTPServerHandshake where smpEncode XFTPServerHandshake {xftpVersionRange, sessionId, authPubKey} = diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 4fbcae425..27223b12f 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -688,10 +688,9 @@ getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId connectClient :: XFTPClientVar -> AM XFTPClient connectClient v = do cfg <- asks $ xftpCfg . config - g <- asks random xftpNetworkConfig <- atomically $ getNetworkConfig c liftError' (protocolClientError XFTP $ B.unpack $ strEncode srv) $ - X.getXFTPClient g tSess cfg {xftpNetworkConfig} $ + X.getXFTPClient tSess cfg {xftpNetworkConfig} $ clientDisconnected v clientDisconnected :: XFTPClientVar -> XFTPClient -> IO () @@ -1008,7 +1007,7 @@ runXFTPServerTest c userId (ProtoServerWithAuth srv auth) = do rcvPath <- getTempFilePath workDir liftIO $ do let tSess = (userId, srv, Nothing) - X.getXFTPClient g tSess cfg {xftpNetworkConfig} (\_ -> pure ()) >>= \case + X.getXFTPClient tSess cfg {xftpNetworkConfig} (\_ -> pure ()) >>= \case Right xftp -> withTestChunk filePath $ do (sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g (rcvKey, rpKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 61ec96003..60ec0e25f 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -129,7 +129,6 @@ data PClient v err msg = PClient transportSession :: TransportSession msg, transportHost :: TransportHost, tcpTimeout :: Int, - batchDelay :: Maybe Int, pingErrorCount :: TVar Int, clientCorrId :: TVar ChaChaDRG, sentCommands :: TMap CorrId (Request err msg), @@ -165,7 +164,6 @@ smpClientStub g sessionId thVersion thAuth = do transportSession = (1, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001", Nothing), transportHost = "localhost", tcpTimeout = 15_000_000, - batchDelay = Nothing, pingErrorCount, clientCorrId, sentCommands, @@ -252,8 +250,8 @@ data ProtocolClientConfig v = ProtocolClientConfig networkConfig :: NetworkConfig, -- | client-server protocol version range serverVRange :: VersionRange v, - -- | delay between sending batches of commands (microseconds) - batchDelay :: Maybe Int + -- | agree shared session secret (used in SMP proxy) + agreeSecret :: Bool } -- | Default protocol client configuration. @@ -264,7 +262,7 @@ defaultClientConfig serverVRange = defaultTransport = ("443", transport @TLS), networkConfig = defaultNetworkConfig, serverVRange, - batchDelay = Nothing + agreeSecret = False } {-# INLINE defaultClientConfig #-} @@ -321,7 +319,7 @@ type TransportSession msg = (UserId, ProtoServer msg, Maybe EntityId) -- A single queue can be used for multiple 'SMPClient' instances, -- as 'SMPServerTransmission' includes server information. getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> TransportSession msg -> ProtocolClientConfig v -> Maybe (TBQueue (ServerTransmission v msg)) -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) -getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, serverVRange, batchDelay} msgQ disconnected = do +getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, serverVRange, agreeSecret} msgQ disconnected = do case chooseTransportHost networkConfig (host srv) of Right useHost -> (atomically (mkProtocolClient useHost) >>= runClient useTransport useHost) @@ -343,7 +341,6 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize transportSession, transportHost, tcpTimeout, - batchDelay, pingErrorCount, clientCorrId, sentCommands, @@ -375,7 +372,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize client :: forall c. Transport c => TProxy c -> PClient v err msg -> TMVar (Either (ProtocolClientError err) (ProtocolClient v err msg)) -> c -> IO () client _ c cVar h = do - ks <- atomically $ C.generateKeyPair g + ks <- if agreeSecret then Just <$> atomically (C.generateKeyPair g) else pure Nothing runExceptT (protocolClientHandshake @v @err @msg h ks (keyHash srv) serverVRange) >>= \case Left e -> atomically . putTMVar cVar . Left $ PCETransportError e Right th@THandle {params} -> do diff --git a/src/Simplex/Messaging/Notifications/Protocol.hs b/src/Simplex/Messaging/Notifications/Protocol.hs index 2c3d2e4df..58a5e5193 100644 --- a/src/Simplex/Messaging/Notifications/Protocol.hs +++ b/src/Simplex/Messaging/Notifications/Protocol.hs @@ -152,7 +152,7 @@ instance Encoding ANewNtfEntity where instance Protocol NTFVersion ErrorType NtfResponse where type ProtoCommand NtfResponse = NtfCmd type ProtoType NtfResponse = 'PNTF - protocolClientHandshake = ntfClientHandshake + protocolClientHandshake c _ks = ntfClientHandshake c protocolPing = NtfCmd SSubscription PING protocolError = \case NRErr e -> Just e diff --git a/src/Simplex/Messaging/Notifications/Server/Main.hs b/src/Simplex/Messaging/Notifications/Server/Main.hs index 2f6ee7e29..a8d16d85d 100644 --- a/src/Simplex/Messaging/Notifications/Server/Main.hs +++ b/src/Simplex/Messaging/Notifications/Server/Main.hs @@ -13,8 +13,7 @@ import Data.Maybe (fromMaybe) import qualified Data.Text as T import Network.Socket (HostName) import Options.Applicative -import Simplex.Messaging.Client (ProtocolClientConfig (..)) -import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) +import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Server (runNtfServer) import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..), defaultInactiveClientExpiration) @@ -31,9 +30,6 @@ import System.FilePath (combine) import System.IO (BufferMode (..), hSetBuffering, stderr, stdout) import Text.Read (readMaybe) -defaultSMPBatchDelay :: Int -defaultSMPBatchDelay = 10000 - ntfServerCLI :: FilePath -> FilePath -> IO () ntfServerCLI cfgPath logPath = getCliCommand' (cliCommandP cfgPath logPath iniFile) serverVersion >>= \case @@ -87,9 +83,7 @@ ntfServerCLI cfgPath logPath = \# host is only used to print server address on start\n" <> ("host: " <> host <> "\n") <> ("port: " <> defaultServerPort <> "\n") - <> "log_tls_errors: off\n\ - \# delay between command batches sent to SMP relays (microseconds), 0 to disable\n" - <> ("smp_batch_delay: " <> show defaultSMPBatchDelay <> "\n") + <> "log_tls_errors: off\n" <> "websockets: off\n\n\ \[INACTIVE_CLIENTS]\n\ \# TTL and interval to check inactive clients\n\ @@ -111,8 +105,6 @@ ntfServerCLI cfgPath logPath = enableStoreLog = settingIsOn "STORE_LOG" "enable" ini logStats = settingIsOn "STORE_LOG" "log_stats" ini c = combine cfgPath . ($ defaultX509Config) - smpBatchDelay = readIniDefault defaultSMPBatchDelay "TRANSPORT" "smp_batch_delay" ini - batchDelay = if smpBatchDelay <= 0 then Nothing else Just smpBatchDelay serverConfig = NtfServerConfig { transports = iniTransports ini, @@ -121,7 +113,7 @@ ntfServerCLI cfgPath logPath = clientQSize = 64, subQSize = 512, pushQSize = 1048, - smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {batchDelay}}, + smpAgentCfg = defaultSMPClientAgentConfig, apnsConfig = defaultAPNSPushClientConfig, subsBatchSize = 900, inactiveClientExpiration = diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index d4a4a4cbe..022403471 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -68,9 +68,7 @@ data NtfClientHandshake = NtfClientHandshake { -- | agreed SMP notifications server protocol version ntfVersion :: VersionNTF, -- | server identity - CA certificate fingerprint - keyHash :: C.KeyHash, - -- pub key to agree shared secret for entity ID encryption, shared secret for command authorization is agreed using per-queue keys. - authPubKey :: Maybe C.PublicKeyX25519 + keyHash :: C.KeyHash } instance Encoding NtfServerHandshake where @@ -95,21 +93,11 @@ authEncryptCmdsP :: VersionNTF -> Parser a -> Parser (Maybe a) authEncryptCmdsP v p = if v >= authBatchCmdsNTFVersion then Just <$> p else pure Nothing instance Encoding NtfClientHandshake where - smpEncode NtfClientHandshake {ntfVersion, keyHash, authPubKey} = - smpEncode (ntfVersion, keyHash) <> encodeNtfAuthPubKey ntfVersion authPubKey + smpEncode NtfClientHandshake {ntfVersion, keyHash} = + smpEncode (ntfVersion, keyHash) smpP = do (ntfVersion, keyHash) <- smpP - -- TODO drop SMP v6: remove special parser and make key non-optional - authPubKey <- ntfAuthPubKeyP ntfVersion - pure NtfClientHandshake {ntfVersion, keyHash, authPubKey} - -ntfAuthPubKeyP :: VersionNTF -> Parser (Maybe C.PublicKeyX25519) -ntfAuthPubKeyP v = if v >= authBatchCmdsNTFVersion then Just <$> smpP else pure Nothing - -encodeNtfAuthPubKey :: VersionNTF -> Maybe C.PublicKeyX25519 -> ByteString -encodeNtfAuthPubKey v k - | v >= authBatchCmdsNTFVersion = maybe "" smpEncode k - | otherwise = "" + pure NtfClientHandshake {ntfVersion, keyHash} -- | Notifcations server transport handshake. ntfServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TServer) @@ -118,16 +106,16 @@ ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do let sk = C.signX509 serverSignKey $ C.publicToX509 k sendHandshake th $ NtfServerHandshake {sessionId, ntfVersionRange = ntfVRange, authPubKey = Just sk} getHandshake th >>= \case - NtfClientHandshake {ntfVersion = v, keyHash, authPubKey = k'} + NtfClientHandshake {ntfVersion = v, keyHash} | keyHash /= kh -> throwError $ TEHandshake IDENTITY | v `isCompatible` ntfVRange -> - pure $ ntfThHandleServer th v pk k' + pure $ ntfThHandleServer th v pk | otherwise -> throwError $ TEHandshake VERSION -- | Notifcations server client transport handshake. -ntfClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TClient) -ntfClientHandshake c (k, pk) keyHash ntfVRange = do +ntfClientHandshake :: forall c. Transport c => c -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TClient) +ntfClientHandshake c keyHash ntfVRange = do let th@THandle {params = THandleParams {sessionId}} = ntfTHandle c NtfServerHandshake {sessionId = sessId, ntfVersionRange, authPubKey = sk'} <- getHandshake th if sessionId /= sessId @@ -138,18 +126,18 @@ ntfClientHandshake c (k, pk) keyHash ntfVRange = do serverKey <- getServerVerifyKey c pubKey <- C.verifyX509 serverKey signedKey (,(getServerCerts c, signedKey)) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) - sendHandshake th $ NtfClientHandshake {ntfVersion = v, keyHash, authPubKey = Just k} - pure $ ntfThHandleClient th v pk ck_ + sendHandshake th $ NtfClientHandshake {ntfVersion = v, keyHash} + pure $ ntfThHandleClient th v ck_ Nothing -> throwError $ TEHandshake VERSION -ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleNTF c 'TServer -ntfThHandleServer th v pk k_ = - let thAuth = (\k -> THAuthServer {clientPeerPubKey = k, serverPrivKey = pk}) <$> k_ - in ntfThHandle_ th v thAuth +ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> C.PrivateKeyX25519 -> THandleNTF c 'TServer +ntfThHandleServer th v pk = + let thAuth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} + in ntfThHandle_ th v (Just thAuth) -ntfThHandleClient :: forall c. THandleNTF c 'TClient -> VersionNTF -> C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleNTF c 'TClient -ntfThHandleClient th v pk ck_ = - let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, clientPrivKey = pk}) <$> ck_ +ntfThHandleClient :: forall c. THandleNTF c 'TClient -> VersionNTF -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleNTF c 'TClient +ntfThHandleClient th v ck_ = + let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, sessSecret = Nothing}) <$> ck_ in ntfThHandle_ th v thAuth ntfThHandle_ :: forall c p. THandleNTF c p -> VersionNTF -> Maybe (THandleAuth p) -> THandleNTF c p diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 31577ccdb..a52c36db0 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -1092,7 +1092,7 @@ transmissionP THandleParams {sessionId, implySessId} = do class (ProtocolEncoding v err msg, ProtocolEncoding v err (ProtoCommand msg), Show err, Show msg) => Protocol v err msg | msg -> v, msg -> err where type ProtoCommand msg = cmd | cmd -> msg type ProtoType msg = (sch :: ProtocolType) | sch -> msg - protocolClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRange v -> ExceptT TransportError IO (THandle v c 'TClient) + protocolClientHandshake :: forall c. Transport c => c -> Maybe C.KeyPairX25519 -> C.KeyHash -> VersionRange v -> ExceptT TransportError IO (THandle v c 'TClient) protocolPing :: ProtoCommand msg protocolError :: msg -> Maybe err diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 6d4ccc83b..519154bb5 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -78,7 +78,7 @@ module Simplex.Messaging.Transport ) where -import Control.Applicative ((<|>)) +import Control.Applicative (optional, (<|>)) import Control.Monad (forM) import Control.Monad.Except import Control.Monad.Trans.Except (throwE) @@ -336,14 +336,14 @@ data THandleParams v p = THandleParams data THandleAuth (p :: TransportPeer) where THAuthClient :: - { serverPeerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key - serverCertKey :: (X.CertificateChain, X.SignedExact X.PubKey), -- the key here is clientPrivKey signed with server certificate - clientPrivKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) + { serverPeerPubKey :: C.PublicKeyX25519, -- used by the client to combine with client's private per-queue key + serverCertKey :: (X.CertificateChain, X.SignedExact X.PubKey), -- the key here is serverPeerPubKey signed with server certificate + sessSecret :: Maybe C.DhSecretX25519 -- session secret (will be used in SMP proxy only) } -> THandleAuth 'TClient THAuthServer :: - { clientPeerPubKey :: C.PublicKeyX25519, -- used only in the client to combine with per-queue key - serverPrivKey :: C.PrivateKeyX25519 -- used to combine with peer's per-queue key (currently only in the server) + { serverPrivKey :: C.PrivateKeyX25519, -- used by the server to combine with client's public per-queue key + sessSecret' :: Maybe C.DhSecretX25519 -- session secret (will be used in SMP proxy only) } -> THandleAuth 'TServer @@ -399,7 +399,7 @@ encodeAuthEncryptCmds v k | otherwise = "" authEncryptCmdsP :: VersionSMP -> Parser a -> Parser (Maybe a) -authEncryptCmdsP v p = if v >= authCmdsSMPVersion then Just <$> p else pure Nothing +authEncryptCmdsP v p = if v >= authCmdsSMPVersion then optional p else pure Nothing -- | Error of SMP encrypted transport over TCP. data TransportError @@ -480,8 +480,8 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do -- | Client SMP transport handshake. -- -- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a -smpClientHandshake :: forall c. Transport c => c -> C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c 'TClient) -smpClientHandshake c (k, pk) keyHash@(C.KeyHash kh) smpVRange = do +smpClientHandshake :: forall c. Transport c => c -> Maybe C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> ExceptT TransportError IO (THandleSMP c 'TClient) +smpClientHandshake c ks_ keyHash@(C.KeyHash kh) smpVRange = do let th@THandle {params = THandleParams {sessionId}} = smpTHandle c ServerHandshake {sessionId = sessId, smpVersionRange, authPubKey} <- getHandshake th if sessionId /= sessId @@ -496,18 +496,18 @@ smpClientHandshake c (k, pk) keyHash@(C.KeyHash kh) smpVRange = do serverKey <- getServerVerifyKey c pubKey <- C.verifyX509 serverKey exact (,certKey) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) - sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = Just k} - pure $ smpThHandleClient th v pk ck_ + sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = fst <$> ks_} + pure $ smpThHandleClient th v (snd <$> ks_) ck_ Nothing -> throwE $ TEHandshake VERSION smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer smpThHandleServer th v pk k_ = - let thAuth = (\k -> THAuthServer {clientPeerPubKey = k, serverPrivKey = pk}) <$> k_ - in smpThHandle_ th v thAuth + let thAuth = THAuthServer {serverPrivKey = pk, sessSecret' = (`C.dh'` pk) <$> k_} + in smpThHandle_ th v (Just thAuth) -smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient -smpThHandleClient th v pk ck_ = - let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, clientPrivKey = pk}) <$> ck_ +smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> Maybe C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient +smpThHandleClient th v pk_ ck_ = + let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, sessSecret = C.dh' k <$> pk_}) <$> ck_ in smpThHandle_ th v thAuth smpThHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> Maybe (THandleAuth p) -> THandleSMP c p diff --git a/tests/CoreTests/BatchingTests.hs b/tests/CoreTests/BatchingTests.hs index c349096dc..5a60a1635 100644 --- a/tests/CoreTests/BatchingTests.hs +++ b/tests/CoreTests/BatchingTests.hs @@ -333,14 +333,13 @@ testTHandleParams v sessionId = testTHandleAuth :: VersionSMP -> TVar ChaChaDRG -> C.APublicAuthKey -> IO (Maybe (THandleAuth 'TClient)) testTHandleAuth v g (C.APublicAuthKey a serverPeerPubKey) = case a of C.SX25519 | v >= authCmdsSMPVersion -> do - (_, clientPrivKey) <- atomically $ C.generateKeyPair @'C.X25519 g ca <- head <$> XS.readCertificates "tests/fixtures/ca.crt" serverCert <- head <$> XS.readCertificates "tests/fixtures/server.crt" serverKey <- head <$> XF.readKeyFile "tests/fixtures/server.key" signKey <- either error pure $ C.x509ToPrivate (serverKey, []) >>= C.privKey @C.APrivateSignKey (serverAuthPub, _) <- atomically $ C.generateKeyPair @'C.X25519 g let serverCertKey = (X.CertificateChain [serverCert, ca], C.signX509 signKey $ C.toPubKey C.publicToX509 serverAuthPub) - pure $ Just THAuthClient {serverPeerPubKey, serverCertKey, clientPrivKey} + pure $ Just THAuthClient {serverPeerPubKey, serverCertKey, sessSecret = Nothing} _ -> pure Nothing randomSENDCmd :: ProtocolClient SMPVersion ErrorType BrokerMsg -> Int -> IO (PCTransmission ErrorType BrokerMsg) diff --git a/tests/NtfClient.hs b/tests/NtfClient.hs index d47722944..46a199777 100644 --- a/tests/NtfClient.hs +++ b/tests/NtfClient.hs @@ -73,10 +73,8 @@ ntfTestStoreLogFile = "tests/tmp/ntf-server-store.log" testNtfClient :: Transport c => (THandleNTF c 'TClient -> IO a) -> IO a testNtfClient client = do Right host <- pure $ chooseTransportHost defaultNetworkConfig testHost - runTransportClient defaultTransportClientConfig Nothing host ntfTestPort (Just testKeyHash) $ \h -> do - g <- C.newRandom - ks <- atomically $ C.generateKeyPair g - runExceptT (ntfClientHandshake h ks testKeyHash supportedClientNTFVRange) >>= \case + runTransportClient defaultTransportClientConfig Nothing host ntfTestPort (Just testKeyHash) $ \h -> + runExceptT (ntfClientHandshake h testKeyHash supportedClientNTFVRange) >>= \case Right th -> client th Left e -> error $ show e diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 9023574d8..cf222c3b4 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -73,10 +73,8 @@ testSMPClient = testSMPClientVR supportedClientSMPRelayVRange testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a testSMPClientVR vr client = do Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost - runTransportClient defaultTransportClientConfig Nothing useHost testPort (Just testKeyHash) $ \h -> do - g <- C.newRandom - ks <- atomically $ C.generateKeyPair g - runExceptT (smpClientHandshake h ks testKeyHash vr) >>= \case + runTransportClient defaultTransportClientConfig Nothing useHost testPort (Just testKeyHash) $ \h -> + runExceptT (smpClientHandshake h Nothing testKeyHash vr) >>= \case Right th -> client th Left e -> error $ show e diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index 4e926f1fd..5f38cc639 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -14,7 +14,6 @@ import Simplex.FileTransfer.Client import Simplex.FileTransfer.Description import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration, defaultInactiveClientExpiration, supportedXFTPhandshakes) -import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (XFTPServer) import Simplex.Messaging.Transport (ALPN) import Simplex.Messaging.Transport.Server @@ -133,8 +132,7 @@ testXFTPClient :: HasCallStack => (HasCallStack => XFTPClient -> IO a) -> IO a testXFTPClient = testXFTPClientWith testXFTPClientConfig testXFTPClientWith :: HasCallStack => XFTPClientConfig -> (HasCallStack => XFTPClient -> IO a) -> IO a -testXFTPClientWith cfg client = do - g <- C.newRandom - getXFTPClient g (1, testXFTPServer, Nothing) cfg (\_ -> pure ()) >>= \case +testXFTPClientWith cfg client = + getXFTPClient (1, testXFTPServer, Nothing) cfg (\_ -> pure ()) >>= \case Right c -> client c Left e -> error $ show e diff --git a/tests/XFTPServerTests.hs b/tests/XFTPServerTests.hs index 494f624fd..19713d8b1 100644 --- a/tests/XFTPServerTests.hs +++ b/tests/XFTPServerTests.hs @@ -219,8 +219,7 @@ testFileChunkExpiration = withXFTPServerCfg testXFTPServerConfig {fileExpiration testInactiveClientExpiration :: Expectation testInactiveClientExpiration = withXFTPServerCfg testXFTPServerConfig {inactiveClientExpiration} $ \_ -> runRight_ $ do disconnected <- newEmptyTMVarIO - g <- liftIO C.newRandom - c <- ExceptT $ getXFTPClient g (1, testXFTPServer, Nothing) testXFTPClientConfig (\_ -> atomically $ putTMVar disconnected ()) + c <- ExceptT $ getXFTPClient (1, testXFTPServer, Nothing) testXFTPClientConfig (\_ -> atomically $ putTMVar disconnected ()) pingXFTP c liftIO $ do threadDelay 100000 From 58ede38bf4e011d99248bb571283a0d255b7a833 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 19 Apr 2024 16:58:15 +0300 Subject: [PATCH 010/107] put smp errors into proxy wrappers (#1103) * put smp errors into proxy wrappers * use substring in PROXY UNEXPECTED error * fix encoding * revert String encoding, discard invalid errors in QC --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Client.hs | 9 ++-- src/Simplex/Messaging/Encoding/String.hs | 2 + src/Simplex/Messaging/Protocol.hs | 54 +++++++++++++++--------- src/Simplex/Messaging/Server.hs | 2 +- tests/CoreTests/ProtocolErrorTests.hs | 19 ++++++--- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 88afea56b..11243b4e0 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -484,16 +484,15 @@ temporaryClientError = \case _ -> False {-# INLINE temporaryClientError #-} --- TODO keep error params smpProxyError :: SMPClientError -> ErrorType smpProxyError = \case - PCEProtocolError _ -> PROXY PROTOCOL - PCEResponseError _ -> PROXY RESPONSE - PCEUnexpectedResponse _ -> PROXY UNEXPECTED + PCEProtocolError et -> PROXY (PROTOCOL et) + PCEResponseError et -> PROXY (RESPONSE et) + PCEUnexpectedResponse bs -> PROXY (UNEXPECTED $ B.unpack $ B.take 32 bs) PCEResponseTimeout -> PROXY TIMEOUT PCENetworkError -> PROXY NETWORK PCEIncompatibleHost -> PROXY BAD_HOST - PCETransportError _ -> PROXY TRANSPORT + PCETransportError t -> PROXY (TRANSPORT t) PCECryptoError _ -> INTERNAL PCEIOError _ -> INTERNAL diff --git a/src/Simplex/Messaging/Encoding/String.hs b/src/Simplex/Messaging/Encoding/String.hs index fcefdc73d..6b9fb5624 100644 --- a/src/Simplex/Messaging/Encoding/String.hs +++ b/src/Simplex/Messaging/Encoding/String.hs @@ -75,6 +75,8 @@ instance StrEncoding Str where strEncode = unStr strP = Str <$> A.takeTill (== ' ') <* optional A.space +-- inherited from ByteString, the parser only allows non-empty strings +-- only Char8 elements may round-trip as B.pack truncates unicode instance StrEncoding String where strEncode = strEncode . B.pack strP = B.unpack <$> strP diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 3ad510481..fafd8a340 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -195,6 +195,8 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Maybe (isJust, isNothing) import Data.String +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock.System (SystemTime (..)) import Data.Type.Equality import Data.Word (Word16) @@ -210,7 +212,7 @@ import Simplex.Messaging.Parsers import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) -import Simplex.Messaging.Util (bshow, eitherToMaybe, (<$?>)) +import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal @@ -1167,11 +1169,11 @@ data ErrorType instance StrEncoding ErrorType where strEncode = \case CMD e -> "CMD " <> bshow e - PROXY e -> "PROXY " <> bshow e + PROXY e -> "PROXY " <> strEncode e e -> bshow e strP = "CMD " *> (CMD <$> parseRead1) - <|> "PROXY " *> (PROXY <$> parseRead1) + <|> "PROXY " *> (PROXY <$> strP) <|> parseRead1 -- | SMP command error type. @@ -1190,20 +1192,19 @@ data CommandError NO_ENTITY deriving (Eq, Read, Show) --- TODO keep error params data ProxyError = -- | Correctly parsed SMP server ERR response. -- This error is forwarded to the agent client as `ERR SMP err`. - PROTOCOL -- {protocolErr :: String} + PROTOCOL {protocolErr :: ErrorType} | -- | Invalid server response that failed to parse. -- Forwarded to the agent client as `ERR BROKER RESPONSE`. - RESPONSE -- {responseErr :: String} - | UNEXPECTED + RESPONSE {responseErr :: ErrorType} + | UNEXPECTED {unexpectedResponse :: String} -- 'String' for using derived JSON and Arbitrary instances | TIMEOUT | NETWORK | BAD_HOST | NO_SESSION - | TRANSPORT -- {transportErr :: TransportError} + | TRANSPORT {transportErr :: TransportError} deriving (Eq, Read, Show) -- | SMP transmission parser. @@ -1473,26 +1474,42 @@ instance Encoding CommandError where instance Encoding ProxyError where smpEncode e = case e of - PROTOCOL -> "PROTOCOL" - RESPONSE -> "RESPONSE" - UNEXPECTED -> "UNEXPECTED" + PROTOCOL et -> "PROTOCOL " <> smpEncode et + RESPONSE et -> "RESPONSE " <> smpEncode et + UNEXPECTED s -> "UNEXPECTED " <> smpEncode (encodeUtf8 $ T.pack s) TIMEOUT -> "TIMEOUT" NETWORK -> "NETWORK" BAD_HOST -> "BAD_HOST" NO_SESSION -> "NO_SESSION" - TRANSPORT -> "TRANSPORT" + TRANSPORT t -> "TRANSPORT " <> serializeTransportError t smpP = A.takeTill (== ' ') >>= \case - "PROTOCOL" -> pure PROTOCOL - "RESPONSE" -> pure RESPONSE - "UNEXPECTED" -> pure UNEXPECTED + "PROTOCOL" -> PROTOCOL <$> _smpP + "RESPONSE" -> RESPONSE <$> _smpP + "UNEXPECTED" -> UNEXPECTED . (T.unpack . safeDecodeUtf8) <$> _smpP "TIMEOUT" -> pure TIMEOUT "NETWORK" -> pure NETWORK "BAD_HOST" -> pure BAD_HOST "NO_SESSION" -> pure NO_SESSION - "TRANSPORT" -> pure TRANSPORT + "TRANSPORT" -> TRANSPORT <$> (A.space *> transportErrorP) _ -> fail "bad command error type" +instance StrEncoding ProxyError where + strEncode = \case + PROTOCOL et -> "PROTOCOL " <> strEncode et + RESPONSE et -> "RESPONSE " <> strEncode et + UNEXPECTED "" -> "UNEXPECTED" -- Arbitrary instance generates empty strings which String instance can't handle + UNEXPECTED s -> "UNEXPECTED " <> strEncode s + TRANSPORT t -> "TRANSPORT " <> serializeTransportError t + e -> bshow e + strP = + "PROTOCOL " *> (PROTOCOL <$> strP) + <|> "RESPONSE " *> (RESPONSE <$> strP) + <|> "UNEXPECTED " *> (UNEXPECTED <$> strP) + <|> "UNEXPECTED" $> UNEXPECTED "" + <|> "TRANSPORT " *> (TRANSPORT <$> transportErrorP) + <|> parseRead1 + -- | Send signed SMP transmission to TCP transport. tPut :: Transport c => THandle v c p -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] tPut th@THandle {params} = fmap concat . mapM tPutBatch . batchTransmissions (batch params) (blockSize params) @@ -1630,6 +1647,5 @@ $(J.deriveJSON defaultJSON ''MsgFlags) $(J.deriveJSON (sumTypeJSON id) ''CommandError) -$(J.deriveJSON (sumTypeJSON id) ''ProxyError) - -$(J.deriveJSON (sumTypeJSON id) ''ErrorType) +-- run deriveJSON in one TH splice to allow mutual instance +$(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''ErrorType]) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 959339fad..b4c9722f7 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -632,7 +632,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi vr = supportedServerSMPRelayVRange in case thAuth of Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey - Nothing -> ERR $ PROXY TRANSPORT -- TODO different error? + Nothing -> ERR $ PROXY (TRANSPORT TENoServerAuth) Left err -> ERR $ smpProxyError err PFWD pubKey encBlock -> do ProxyAgent {smpAgent} <- asks proxyAgent diff --git a/tests/CoreTests/ProtocolErrorTests.hs b/tests/CoreTests/ProtocolErrorTests.hs index a486e6549..8f5ad70e7 100644 --- a/tests/CoreTests/ProtocolErrorTests.hs +++ b/tests/CoreTests/ProtocolErrorTests.hs @@ -17,6 +17,7 @@ import qualified Simplex.Messaging.Agent.Protocol as Agent import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (CommandError (..), ErrorType (..), ProxyError (..)) +import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport (HandshakeError (..), TransportError (..)) import Simplex.RemoteControl.Types (RCErrorType (..)) import Test.Hspec @@ -28,15 +29,19 @@ protocolErrorTests = modifyMaxSuccess (const 1000) $ do describe "errors parsing / serializing" $ do it "should parse SMP protocol errors" . property $ \(err :: ErrorType) -> smpDecode (smpEncode err) == Right err - it "should parse SMP agent errors" . property $ \(err :: AgentErrorType) -> - errHasSpaces err - || strDecode (strEncode err) == Right err + it "should parse SMP agent errors" . property . forAll possible $ \err -> + strDecode (strEncode err) == Right err where - errHasSpaces = \case - BROKER srv (Agent.RESPONSE e) -> hasSpaces srv || hasSpaces e - BROKER srv _ -> hasSpaces srv - _ -> False + possible :: Gen AgentErrorType + possible = + arbitrary >>= \case + BROKER srv (Agent.RESPONSE e) | hasSpaces srv || hasSpaces e -> discard + BROKER srv _ | hasSpaces srv -> discard + SMP (PROXY (SMP.UNEXPECTED s)) | hasUnicode s -> discard + NTF (PROXY (SMP.UNEXPECTED s)) | hasUnicode s -> discard + ok -> pure ok hasSpaces s = ' ' `B.elem` encodeUtf8 (T.pack s) + hasUnicode = any (>= '\255') deriving instance Generic AgentErrorType From 2d1609f222a38250db49d43998743d9bf52fc3a5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 19 Apr 2024 20:24:25 +0100 Subject: [PATCH 011/107] update envelope sizes for proxied messages, remove unnecessary proxy-relay encryption padding (#1107) * update envelope sizes for proxied messages * remove unnecessary padding from proxy-relay encryption --- src/Simplex/Messaging/Client.hs | 4 ++-- src/Simplex/Messaging/Protocol.hs | 26 +++++++++----------------- src/Simplex/Messaging/Server.hs | 7 ++++--- src/Simplex/Messaging/Transport.hs | 5 ----- tests/SMPProxyTests.hs | 15 ++++++++------- tests/ServerTests.hs | 4 ++-- 6 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 11243b4e0..8d4db29d9 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -732,12 +732,12 @@ forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = nonce <- liftIO . atomically $ C.randomCbNonce g -- wrap let fwdT = FwdTransmission {fwdCorrId, fwdKey, fwdTransmission} - eft <- liftEitherWith PCECryptoError $ EncFwdTransmission <$> C.cbEncrypt sessSecret nonce (smpEncode fwdT) paddedForwardedMsgLength + eft = EncFwdTransmission $ C.cbEncryptNoPad sessSecret nonce (smpEncode fwdT) -- send sendProtocolCommand_ c (Just nonce) Nothing "" (Cmd SSender (RFWD eft)) >>= \case RRES (EncFwdResponse efr) -> do -- unwrap - r' <- liftEitherWith PCECryptoError $ C.cbDecrypt sessSecret (C.reverseNonce nonce) efr + r' <- liftEitherWith PCECryptoError $ C.cbDecryptNoPad sessSecret (C.reverseNonce nonce) efr FwdResponse {fwdCorrId = _, fwdResponse} <- liftEitherWith (const $ PCEResponseError BLOCK) $ smpDecode r' pure fwdResponse r -> throwE . PCEUnexpectedResponse $ bshow r diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index fafd8a340..118f3b084 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -44,7 +44,6 @@ module Simplex.Messaging.Protocol supportedSMPClientVRange, maxMessageLength, paddedProxiedMsgLength, - paddedForwardedMsgLength, e2eEncConfirmationLength, e2eEncMessageLength, @@ -243,23 +242,16 @@ currentSMPClientVersion = VersionSMPC 2 supportedSMPClientVRange :: VersionRangeSMPC supportedSMPClientVRange = mkVersionRange initialSMPClientVersion currentSMPClientVersion -maxMessageLength :: Int -maxMessageLength = 16088 +-- TODO v6.0 remove dependency on version +maxMessageLength :: VersionSMP -> Int +maxMessageLength v + | v >= sendingProxySMPVersion = 16064 -- max 16067 + | otherwise = 16088 -- 16064 - always use this size to determine allowed ranges --- without signature works with min 16151 (fails with 16150) --- with Ed448: 16265 (fails with 16264) --- with Ed25519: 16215 (fails with 16214) --- with X25519: 16232 (fails with 16231) paddedProxiedMsgLength :: Int -paddedProxiedMsgLength = 16232 - --- without signature works with min 16239 (fails with 16238) --- with Ed448: 16353 (fails with 16352) --- with Ed25519: 16303 (fails with 16302) --- with X25519: 16320 (fails with 16319) -paddedForwardedMsgLength :: Int -paddedForwardedMsgLength = 16320 +paddedProxiedMsgLength = 16244 -- 16241 .. 16245 +-- TODO v6.0 change to 16064 type MaxMessageLen = 16088 -- 16 extra bytes: 8 for timestamp and 8 for flags (7 flags and the space, only 1 flag is currently used) @@ -267,10 +259,10 @@ type MaxRcvMessageLen = MaxMessageLen + 16 -- 16104, the padded size is 16106 -- it is shorter to allow per-queue e2e encryption DH key in the "public" header e2eEncConfirmationLength :: Int -e2eEncConfirmationLength = 15936 +e2eEncConfirmationLength = 15920 -- 15881 .. 15976 e2eEncMessageLength :: Int -e2eEncMessageLength = 16032 +e2eEncMessageLength = 16016 -- 16004 .. 16021 -- | SMP protocol clients data Party = Recipient | Sender | Notifier | ProxiedClient diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index b4c9722f7..d1fcfbc24 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -856,7 +856,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi sendMessage :: QueueRec -> MsgFlags -> MsgBody -> M (Transmission BrokerMsg) sendMessage qr msgFlags msgBody - | B.length msgBody > maxMessageLength = pure $ err LARGE_MSG + | B.length msgBody > maxMessageLength thVersion = pure $ err LARGE_MSG | otherwise = case status qr of QueueOff -> return $ err AUTH QueueActive -> @@ -880,6 +880,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi atomically $ updatePeriodStats (activeQueues stats) (recipientId qr) pure ok where + THandleParams {thVersion} = thParams' mkMessage :: C.MaxLenBS MaxMessageLen -> M Message mkMessage body = do msgId <- randomId =<< asks (msgIdBytes . config) @@ -921,7 +922,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi sessSecret <- maybe (throwError $ ERR INTERNAL) pure sessSecret' let proxyNonce = C.cbNonce $ bs corrId -- TODO error - s' <- liftEitherWith internalErr $ C.cbDecrypt sessSecret proxyNonce s + s' <- liftEitherWith internalErr $ C.cbDecryptNoPad sessSecret proxyNonce s -- TODO error FwdTransmission {fwdCorrId, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith internalErr $ smpDecode s' -- TODO error - this error is reported to proxy, as we failed to get to client's transmission @@ -949,7 +950,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi r2 <- liftEitherWith internalErr $ EncResponse <$> C.cbEncrypt clientSecret (C.reverseNonce clientNonce) r' paddedProxiedMsgLength -- encrypt to proxy let fr = FwdResponse {fwdCorrId, fwdResponse = r2} - r3 <- liftEitherWith internalErr $ EncFwdResponse <$> C.cbEncrypt sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) paddedForwardedMsgLength + r3 = EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) pure $ RRES r3 where internalErr _ = ERR INTERNAL -- TODO errors diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 001d77fa7..a353849da 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -115,11 +115,6 @@ import UnliftIO.STM -- * Transport parameters --- min size it works with: --- unsigned message: 16292 (paddedProxiedMsgLength = 16151, paddedForwardedMsgLength = 16239) --- Ed448: 16406 (16384 + 22, fails with 21) --- Ed25519: 16356 --- X25519: 16381 smpBlockSize :: Int smpBlockSize = 16384 diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 1ab2779c8..2a33ec055 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -39,6 +39,7 @@ smpProxyTests = do xit "batching proxy requests" todo describe "forwarding requests" $ do describe "deliver message via SMP proxy" $ do + let maxLen = maxMessageLength sendingProxySMPVersion it "same server" $ withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> do let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash @@ -50,12 +51,12 @@ smpProxyTests = do let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash deliverMessageViaProxy proxyServ relayServ C.SEd448 "hello 1" "hello 2" - xit "max message size, Ed448 keys" $ + it "max message size, Ed448 keys" $ withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do g <- C.newRandom - msg <- atomically $ C.randomBytes maxMessageLength g - msg' <- atomically $ C.randomBytes maxMessageLength g + msg <- atomically $ C.randomBytes maxLen g + msg' <- atomically $ C.randomBytes maxLen g let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash deliverMessageViaProxy proxyServ relayServ C.SEd448 msg msg' @@ -63,8 +64,8 @@ smpProxyTests = do withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do g <- C.newRandom - msg <- atomically $ C.randomBytes maxMessageLength g - msg' <- atomically $ C.randomBytes maxMessageLength g + msg <- atomically $ C.randomBytes maxLen g + msg' <- atomically $ C.randomBytes maxLen g let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash deliverMessageViaProxy proxyServ relayServ C.SEd25519 msg msg' @@ -72,8 +73,8 @@ smpProxyTests = do withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do g <- C.newRandom - msg <- atomically $ C.randomBytes maxMessageLength g - msg' <- atomically $ C.randomBytes maxMessageLength g + msg <- atomically $ C.randomBytes maxLen g + msg' <- atomically $ C.randomBytes maxLen g let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash deliverMessageViaProxy proxyServ relayServ C.SX25519 msg msg' diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index a7e8fee86..b0ed67913 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -183,12 +183,12 @@ testCreateSecure (ATransport t) = Resp "dabc" _ err5 <- sendRecv s ("", "dabc", sId, _SEND "hello") (err5, ERR AUTH) #== "rejects unsigned SEND" - let maxAllowedMessage = B.replicate maxMessageLength '-' + let maxAllowedMessage = B.replicate (maxMessageLength currentClientSMPRelayVersion) '-' Resp "bcda" _ OK <- signSendRecv s sKey ("bcda", sId, _SEND maxAllowedMessage) Resp "" _ (Msg mId3 msg3) <- tGet1 r (dec mId3 msg3, Right maxAllowedMessage) #== "delivers message of max size" - let biggerMessage = B.replicate (maxMessageLength + 1) '-' + let biggerMessage = B.replicate (maxMessageLength currentClientSMPRelayVersion + 1) '-' Resp "bcda" _ (ERR LARGE_MSG) <- signSendRecv s sKey ("bcda", sId, _SEND biggerMessage) pure () From 3ba3172aaf210d219164cb65ce8c46685dbb39dc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 20 Apr 2024 09:15:33 +0100 Subject: [PATCH 012/107] xftp: enable ALPN in XFTP server (#1109) --- src/Simplex/FileTransfer/Server/Main.hs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index 91ba17ff3..d53b3f4fa 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -19,7 +19,7 @@ import Options.Applicative import Simplex.FileTransfer.Chunks import Simplex.FileTransfer.Description (FileSize (..)) import Simplex.FileTransfer.Server (runXFTPServer) -import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defFileExpirationHours, defaultFileExpiration, defaultInactiveClientExpiration) +import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defFileExpirationHours, defaultFileExpiration, defaultInactiveClientExpiration, supportedXFTPhandshakes) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), pattern XFTPServer) @@ -180,7 +180,8 @@ xftpServerCLI cfgPath logPath = do serverStatsBackupFile = logStats $> combine logPath "file-server-stats.log", transportConfig = defaultTransportServerConfig - { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini + { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini, + alpn = Just supportedXFTPhandshakes } } From b98fdb672d011587c00743312ad39b54820f6c8d Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sat, 20 Apr 2024 20:17:48 +0300 Subject: [PATCH 013/107] transport: increase client timeouts, don't send command after timeout (#1110) * transport: fix client handshake timeouts * fix handshake timeout * skip sending requests for timed out responses * expose batch concurrency as PClient field * move to NetworkConfig * remove Request on timeout * use record --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Agent/Client.hs | 4 +- src/Simplex/Messaging/Client.hs | 53 ++++++++++++------- src/Simplex/Messaging/Client/Agent.hs | 4 +- src/Simplex/Messaging/Transport/Client.hs | 31 +++++++---- .../Messaging/Transport/HTTP2/Client.hs | 12 ++++- tests/SMPAgentClient.hs | 4 +- 6 files changed, 71 insertions(+), 37 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 27223b12f..41a7b60c1 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -702,8 +702,8 @@ getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId waitForProtocolClient :: ProtocolTypeI (ProtoType msg) => AgentClient -> TransportSession msg -> ClientVar msg -> AM (Client msg) waitForProtocolClient c (_, srv, _) v = do - NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c - client_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) + NetworkConfig {tcpConnectTimeout, tcpTimeout} <- atomically $ getNetworkConfig c + client_ <- liftIO $ (tcpConnectTimeout + tcpTimeout) `timeout` atomically (readTMVar $ sessionVar v) liftEither $ case client_ of Just (Right smpClient) -> Right smpClient Just (Left e) -> Left e diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 60ec0e25f..6d0e16865 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -107,12 +107,13 @@ import Simplex.Messaging.Protocol import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport -import Simplex.Messaging.Transport.Client (SocksProxy, TransportClientConfig (..), TransportHost (..), runTransportClient) +import Simplex.Messaging.Transport.Client (SocksProxy, TransportClientConfig (..), TransportHost (..), defaultTcpConnectTimeout, runTransportClient) import Simplex.Messaging.Transport.KeepAlive import Simplex.Messaging.Transport.WebSockets (WS) -import Simplex.Messaging.Util (bshow, raceAny_, threadDelay') +import Simplex.Messaging.Util (bshow, raceAny_, threadDelay', whenM) import Simplex.Messaging.Version import System.Timeout (timeout) +import UnliftIO (pooledMapConcurrentlyN) -- | 'SMPClient' is a handle used to send commands to a specific SMP server. -- @@ -129,10 +130,11 @@ data PClient v err msg = PClient transportSession :: TransportSession msg, transportHost :: TransportHost, tcpTimeout :: Int, + rcvConcurrency :: Int, pingErrorCount :: TVar Int, clientCorrId :: TVar ChaChaDRG, sentCommands :: TMap CorrId (Request err msg), - sndQ :: TBQueue ByteString, + sndQ :: TBQueue (TVar Bool, ByteString), rcvQ :: TBQueue (NonEmpty (SignedTransmission err msg)), msgQ :: Maybe (TBQueue (ServerTransmission v msg)) } @@ -164,6 +166,7 @@ smpClientStub g sessionId thVersion thAuth = do transportSession = (1, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001", Nothing), transportHost = "localhost", tcpTimeout = 15_000_000, + rcvConcurrency = 8, pingErrorCount, clientCorrId, sentCommands, @@ -206,6 +209,8 @@ data NetworkConfig = NetworkConfig tcpTimeout :: Int, -- | additional timeout per kilobyte (1024 bytes) to be sent tcpTimeoutPerKb :: Int64, + -- | break response timeouts into groups, so later responses get later deadlines + rcvConcurrency :: Int, -- | TCP keep-alive options, Nothing to skip enabling keep-alive tcpKeepAlive :: Maybe KeepAliveOpts, -- | period for SMP ping commands (microseconds, 0 to disable) @@ -226,9 +231,10 @@ defaultNetworkConfig = hostMode = HMOnionViaSocks, requiredHostMode = False, sessionMode = TSMUser, - tcpConnectTimeout = 20_000_000, + tcpConnectTimeout = defaultTcpConnectTimeout, tcpTimeout = 15_000_000, tcpTimeoutPerKb = 5_000, + rcvConcurrency = 8, tcpKeepAlive = Just defaultKeepAliveOpts, smpPingInterval = 600_000_000, -- 10min smpPingCount = 3, @@ -236,8 +242,8 @@ defaultNetworkConfig = } transportClientConfig :: NetworkConfig -> TransportClientConfig -transportClientConfig NetworkConfig {socksProxy, tcpKeepAlive, logTLSErrors} = - TransportClientConfig {socksProxy, tcpKeepAlive, logTLSErrors, clientCredentials = Nothing, alpn = Nothing} +transportClientConfig NetworkConfig {socksProxy, tcpConnectTimeout, tcpKeepAlive, logTLSErrors} = + TransportClientConfig {socksProxy, tcpConnectTimeout, tcpKeepAlive, logTLSErrors, clientCredentials = Nothing, alpn = Nothing} {-# INLINE transportClientConfig #-} -- | protocol client configuration. @@ -271,7 +277,8 @@ defaultSMPClientConfig = defaultClientConfig supportedClientSMPRelayVRange {-# INLINE defaultSMPClientConfig #-} data Request err msg = Request - { entityId :: EntityId, + { corrId :: CorrId, + entityId :: EntityId, responseVar :: TMVar (Either (ProtocolClientError err) msg) } @@ -326,7 +333,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize `catch` \(e :: IOException) -> pure . Left $ PCEIOError e Left e -> pure $ Left e where - NetworkConfig {tcpConnectTimeout, tcpTimeout, smpPingInterval} = networkConfig + NetworkConfig {tcpConnectTimeout, tcpTimeout, rcvConcurrency, smpPingInterval} = networkConfig mkProtocolClient :: TransportHost -> STM (PClient v err msg) mkProtocolClient transportHost = do connected <- newTVar False @@ -344,6 +351,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize pingErrorCount, clientCorrId, sentCommands, + rcvConcurrency, sndQ, rcvQ, msgQ @@ -358,7 +366,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize async $ runTransportClient tcConfig (Just username) useHost port' (Just $ keyHash srv) (client t c cVar) `finally` atomically (tryPutTMVar cVar $ Left PCENetworkError) - c_ <- tcpConnectTimeout `timeout` atomically (takeTMVar cVar) + c_ <- (tcpConnectTimeout + tcpTimeout) `timeout` atomically (takeTMVar cVar) case c_ of Just (Right c') -> pure $ Right c' {action = Just action} Just (Left e) -> pure $ Left e @@ -385,7 +393,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize `finally` disconnected c' send :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () - send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= tPutLog h + send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= \(active, s) -> whenM (readTVarIO active) (void $ tPutLog h s) receive :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () receive ProtocolClient {client_ = PClient {rcvQ}} h = forever $ tGet h >>= atomically . writeTBQueue rcvQ @@ -674,19 +682,21 @@ streamProtocolCommands c@ProtocolClient {thParams = THandleParams {batch, blockS mapM_ (cb <=< sendBatch c) bs sendBatch :: ProtocolClient v err msg -> TransportBatch (Request err msg) -> IO [Response err msg] -sendBatch c@ProtocolClient {client_ = PClient {sndQ}} b = do +sendBatch c@ProtocolClient {client_ = PClient {rcvConcurrency, sndQ}} b = do case b of TBError e Request {entityId} -> do putStrLn "send error: large message" pure [Response entityId $ Left $ PCETransportError e] TBTransmissions s n rs | n > 0 -> do - atomically $ writeTBQueue sndQ s - mapConcurrently (getResponse c) rs + active <- newTVarIO True + atomically $ writeTBQueue sndQ (active, s) + pooledMapConcurrentlyN rcvConcurrency (getResponse c active) rs | otherwise -> pure [] TBTransmission s r -> do - atomically $ writeTBQueue sndQ s - (: []) <$> getResponse c r + active <- newTVarIO True + atomically $ writeTBQueue sndQ (active, s) + (: []) <$> getResponse c active r -- | Send Protocol command sendProtocolCommand :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg @@ -699,19 +709,22 @@ sendProtocolCommand c@ProtocolClient {client_ = PClient {sndQ}, thParams = THand Left e -> pure . Left $ PCETransportError e Right t | B.length s > blockSize - 2 -> pure . Left $ PCETransportError TELargeMsg - | otherwise -> atomically (writeTBQueue sndQ s) >> response <$> getResponse c r + | otherwise -> do + active <- newTVarIO True + atomically (writeTBQueue sndQ (active, s)) + response <$> getResponse c active r where s | batch = tEncodeBatch1 t | otherwise = tEncode t -- TODO switch to timeout or TimeManager that supports Int64 -getResponse :: ProtocolClient v err msg -> Request err msg -> IO (Response err msg) -getResponse ProtocolClient {client_ = PClient {tcpTimeout, pingErrorCount}} Request {entityId, responseVar} = do +getResponse :: ProtocolClient v err msg -> TVar Bool -> Request err msg -> IO (Response err msg) +getResponse ProtocolClient {client_ = PClient {tcpTimeout, pingErrorCount, sentCommands}} active Request {corrId, entityId, responseVar} = do response <- timeout tcpTimeout (atomically (takeTMVar responseVar)) >>= \case Just r -> atomically (writeTVar pingErrorCount 0) $> r - Nothing -> pure $ Left PCEResponseTimeout + Nothing -> atomically (writeTVar active False >> TM.delete corrId sentCommands) $> Left PCEResponseTimeout pure Response {entityId, response} mkTransmission :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> ClientCommand msg -> IO (PCTransmission err msg) @@ -726,7 +739,7 @@ mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCo getNextCorrId = CorrId <$> C.randomBytes 24 clientCorrId -- also used as nonce mkRequest :: CorrId -> STM (Request err msg) mkRequest corrId = do - r <- Request entId <$> newEmptyTMVar + r <- Request corrId entId <$> newEmptyTMVar TM.insert corrId r sentCommands pure r diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 4b925c6f6..c93fe06e5 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -163,8 +163,8 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, wo waitForSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient waitForSMPClient v = do - let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout}} = smpCfg agentCfg - smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) + let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout, tcpTimeout}} = smpCfg agentCfg + smpClient_ <- liftIO $ (tcpConnectTimeout + tcpTimeout) `timeout` atomically (readTMVar $ sessionVar v) liftEither $ case smpClient_ of Just (Right smpClient) -> Right smpClient Just (Left e) -> Left e diff --git a/src/Simplex/Messaging/Transport/Client.hs b/src/Simplex/Messaging/Transport/Client.hs index daea3982e..3d5188348 100644 --- a/src/Simplex/Messaging/Transport/Client.hs +++ b/src/Simplex/Messaging/Transport/Client.hs @@ -10,6 +10,7 @@ module Simplex.Messaging.Transport.Client runTLSTransportClient, smpClientHandshake, defaultSMPPort, + defaultTcpConnectTimeout, defaultTransportClientConfig, defaultSocksProxy, TransportClientConfig (..), @@ -52,6 +53,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.KeepAlive import Simplex.Messaging.Util (bshow, (<$?>), catchAll, tshow) import System.IO.Error +import System.Timeout (timeout) import Text.Read (readMaybe) import UnliftIO.Exception (IOException) import qualified UnliftIO.Exception as E @@ -112,6 +114,7 @@ instance IsString (NonEmpty TransportHost) where fromString = parseString strDec data TransportClientConfig = TransportClientConfig { socksProxy :: Maybe SocksProxy, + tcpConnectTimeout :: Int, tcpKeepAlive :: Maybe KeepAliveOpts, logTLSErrors :: Bool, clientCredentials :: Maybe (X.CertificateChain, T.PrivKey), @@ -119,8 +122,12 @@ data TransportClientConfig = TransportClientConfig } deriving (Eq, Show) +-- time to resolve host, connect socket, set up TLS +defaultTcpConnectTimeout :: Int +defaultTcpConnectTimeout = 10000000 + defaultTransportClientConfig :: TransportClientConfig -defaultTransportClientConfig = TransportClientConfig Nothing (Just defaultKeepAliveOpts) True Nothing Nothing +defaultTransportClientConfig = TransportClientConfig Nothing defaultTcpConnectTimeout (Just defaultKeepAliveOpts) True Nothing Nothing clientTransportConfig :: TransportClientConfig -> TransportConfig clientTransportConfig TransportClientConfig {logTLSErrors} = @@ -131,7 +138,7 @@ runTransportClient :: Transport c => TransportClientConfig -> Maybe ByteString - runTransportClient = runTLSTransportClient supportedParameters Nothing runTLSTransportClient :: Transport c => T.Supported -> Maybe XS.CertificateStore -> TransportClientConfig -> Maybe ByteString -> TransportHost -> ServiceName -> Maybe C.KeyHash -> (c -> IO a) -> IO a -runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, tcpKeepAlive, clientCredentials, alpn} proxyUsername host port keyHash client = do +runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, tcpConnectTimeout, tcpKeepAlive, clientCredentials, alpn} proxyUsername host port keyHash client = do serverCert <- newEmptyTMVarIO let hostName = B.unpack $ strEncode host clientParams = mkTLSClientParams tlsParams caStore_ hostName port keyHash clientCredentials alpn serverCert @@ -142,13 +149,19 @@ runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, sock <- connectTCP port mapM_ (setSocketKeepAlive sock) tcpKeepAlive `catchAll` \e -> logError ("Error setting TCP keep-alive" <> tshow e) let tCfg = clientTransportConfig cfg - connectTLS (Just hostName) tCfg clientParams sock >>= \tls -> do - chain <- atomically (tryTakeTMVar serverCert) >>= \case - Nothing -> do - logError "onServerCertificate didn't fire or failed to get cert chain" - closeTLS tls >> error "onServerCertificate failed" - Just c -> pure c - getClientConnection tCfg chain tls + timeout tcpConnectTimeout (connectTLS (Just hostName) tCfg clientParams sock) >>= \case + Nothing -> do + close sock + logError "connection timed out" + fail "connection timed out" + Just tls -> do + chain <- + atomically (tryTakeTMVar serverCert) >>= \case + Nothing -> do + logError "onServerCertificate didn't fire or failed to get cert chain" + closeTLS tls >> error "onServerCertificate failed" + Just c -> pure c + getClientConnection tCfg chain tls client c `E.finally` closeConnection c where hostAddr = \case diff --git a/src/Simplex/Messaging/Transport/HTTP2/Client.hs b/src/Simplex/Messaging/Transport/HTTP2/Client.hs index b279c1805..be37a6887 100644 --- a/src/Simplex/Messaging/Transport/HTTP2/Client.hs +++ b/src/Simplex/Messaging/Transport/HTTP2/Client.hs @@ -24,7 +24,7 @@ import Numeric.Natural (Natural) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Transport (ALPN, SessionId, TLS (tlsALPN), getServerCerts, getServerVerifyKey, tlsUniq) -import Simplex.Messaging.Transport.Client (TransportClientConfig (..), TransportHost (..), runTLSTransportClient) +import Simplex.Messaging.Transport.Client (TransportClientConfig (..), TransportHost (..), defaultTcpConnectTimeout, runTLSTransportClient) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM @@ -71,7 +71,15 @@ defaultHTTP2ClientConfig = HTTP2ClientConfig { qSize = 64, connTimeout = 10000000, - transportConfig = TransportClientConfig Nothing Nothing True Nothing Nothing, + transportConfig = + TransportClientConfig + { socksProxy = Nothing, + tcpConnectTimeout = defaultTcpConnectTimeout, + tcpKeepAlive = Nothing, + logTLSErrors = True, + clientCredentials = Nothing, + alpn = Nothing + }, bufferSize = defaultHTTP2BufferSize, bodyHeadSize = 16384, suportedTLSParams = http2TLSParams diff --git a/tests/SMPAgentClient.hs b/tests/SMPAgentClient.hs index 59370e654..5ff9197cb 100644 --- a/tests/SMPAgentClient.hs +++ b/tests/SMPAgentClient.hs @@ -34,7 +34,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Server (runSMPAgentBlocking) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') -import Simplex.Messaging.Client (ProtocolClientConfig (..), chooseTransportHost, defaultSMPClientConfig, defaultNetworkConfig) +import Simplex.Messaging.Client (ProtocolClientConfig (..), chooseTransportHost, defaultNetworkConfig, defaultSMPClientConfig) import Simplex.Messaging.Notifications.Client (defaultNTFClientConfig) import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (NtfServer, ProtoServerWithAuth) @@ -215,7 +215,7 @@ agentCfg = certificateFile = "tests/fixtures/server.crt" } where - networkConfig = defaultNetworkConfig {tcpConnectTimeout = 3_000_000, tcpTimeout = 2_000_000} + networkConfig = defaultNetworkConfig {tcpConnectTimeout = 1_000_000, tcpTimeout = 2_000_000} fastRetryInterval :: RetryInterval fastRetryInterval = defaultReconnectInterval {initialInterval = 50_000} From 3d40393ae83260245c7122b850fb42cd4243deba Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 20 Apr 2024 18:20:18 +0100 Subject: [PATCH 014/107] 5.7.0.0 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index c68416384..f62e739d3 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.6.2.2 +version: 5.7.0.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 6d04f9775..42cebccfa 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.6.2.2 +version: 5.7.0.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 2347b82b47778126b0fb397f9c0f58f5a81b94e1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 21 Apr 2024 22:57:07 +0100 Subject: [PATCH 015/107] adjust timeouts and delayed response errors (#1112) --- src/Simplex/Messaging/Agent.hs | 4 ++-- src/Simplex/Messaging/Agent/Client.hs | 4 ++-- src/Simplex/Messaging/Client.hs | 22 +++++++++---------- src/Simplex/Messaging/Client/Agent.hs | 4 ++-- src/Simplex/Messaging/Notifications/Server.hs | 2 +- src/Simplex/Messaging/Transport/Client.hs | 5 +++-- .../Messaging/Transport/HTTP2/Client.hs | 2 +- 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e0cfb2aca..56a08dd01 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -2046,7 +2046,7 @@ data ACKd = ACKd | ACKPending -- | make sure to ACK or throw in each message processing branch -- it cannot be finally, unfortunately, as sometimes it needs to be ACK+DEL processSMPTransmission :: AgentClient -> ServerTransmission SMPVersion BrokerMsg -> AM () -processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, sessId, rId, cmd) = do +processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, sessId, isResponse, rId, cmd) = do (rq, SomeConn _ conn) <- withStore c (\db -> getRcvConn db srv rId) processSMP rq conn $ toConnData conn where @@ -2221,7 +2221,7 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, ignored = pure "END from disconnected client - ignored" _ -> do logServer "<--" c srv rId $ "unexpected: " <> bshow cmd - notify . ERR $ BROKER (B.unpack $ strEncode srv) UNEXPECTED + notify . ERR $ BROKER (B.unpack $ strEncode srv) $ if isResponse then TIMEOUT else UNEXPECTED where notify :: forall e m. MonadIO m => AEntityI e => ACommand 'Agent e -> m () notify = atomically . notify' diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 41a7b60c1..27223b12f 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -702,8 +702,8 @@ getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId waitForProtocolClient :: ProtocolTypeI (ProtoType msg) => AgentClient -> TransportSession msg -> ClientVar msg -> AM (Client msg) waitForProtocolClient c (_, srv, _) v = do - NetworkConfig {tcpConnectTimeout, tcpTimeout} <- atomically $ getNetworkConfig c - client_ <- liftIO $ (tcpConnectTimeout + tcpTimeout) `timeout` atomically (readTMVar $ sessionVar v) + NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c + client_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) liftEither $ case client_ of Just (Right smpClient) -> Right smpClient Just (Left e) -> Left e diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 6d0e16865..454df91e8 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -182,7 +182,7 @@ type SMPClient = ProtocolClient SMPVersion ErrorType BrokerMsg type ClientCommand msg = (Maybe C.APrivateAuthKey, EntityId, ProtoCommand msg) -- | Type synonym for transmission from some SPM server queue. -type ServerTransmission v msg = (TransportSession msg, Version v, SessionId, EntityId, msg) +type ServerTransmission v msg = (TransportSession msg, Version v, SessionId, Bool, EntityId, msg) data HostMode = -- | prefer (or require) onion hosts when connecting via SOCKS proxy @@ -366,7 +366,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize async $ runTransportClient tcConfig (Just username) useHost port' (Just $ keyHash srv) (client t c cVar) `finally` atomically (tryPutTMVar cVar $ Left PCENetworkError) - c_ <- (tcpConnectTimeout + tcpTimeout) `timeout` atomically (takeTMVar cVar) + c_ <- tcpConnectTimeout `timeout` atomically (takeTMVar cVar) case c_ of Just (Right c') -> pure $ Right c' {action = Just action} Just (Left e) -> pure $ Left e @@ -413,16 +413,16 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize process c = forever $ atomically (readTBQueue $ rcvQ $ client_ c) >>= mapM_ (processMsg c) processMsg :: ProtocolClient v err msg -> SignedTransmission err msg -> IO () - processMsg c@ProtocolClient {client_ = PClient {sentCommands}} (_, _, (corrId, entId, respOrErr)) = - if B.null $ bs corrId - then sendMsg respOrErr - else do + processMsg c@ProtocolClient {client_ = PClient {sentCommands}} (_, _, (corrId, entId, respOrErr)) + | isResponse = atomically (TM.lookup corrId sentCommands) >>= \case Nothing -> sendMsg respOrErr Just Request {entityId, responseVar} -> atomically $ do TM.delete corrId sentCommands putTMVar responseVar $ response entityId + | otherwise = sendMsg respOrErr where + isResponse = not $ B.null $ bs corrId response entityId | entityId == entId = case respOrErr of @@ -433,7 +433,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize | otherwise = Left . PCEUnexpectedResponse $ bshow respOrErr sendMsg :: Either err msg -> IO () sendMsg = \case - Right msg -> atomically $ mapM_ (`writeTBQueue` serverTransmission c entId msg) msgQ + Right msg -> atomically $ mapM_ (`writeTBQueue` serverTransmission c isResponse entId msg) msgQ Left e -> putStrLn $ "SMP client error: " <> show e proxyUsername :: TransportSession msg -> ByteString @@ -530,11 +530,11 @@ processSUBResponse c (Response rId r) = case r of Left e -> pure $ Left e writeSMPMessage :: SMPClient -> RecipientId -> BrokerMsg -> IO () -writeSMPMessage c rId msg = atomically $ mapM_ (`writeTBQueue` serverTransmission c rId msg) (msgQ $ client_ c) +writeSMPMessage c rId msg = atomically $ mapM_ (`writeTBQueue` serverTransmission c False rId msg) (msgQ $ client_ c) -serverTransmission :: ProtocolClient v err msg -> RecipientId -> msg -> ServerTransmission v msg -serverTransmission ProtocolClient {thParams = THandleParams {thVersion, sessionId}, client_ = PClient {transportSession}} entityId message = - (transportSession, thVersion, sessionId, entityId, message) +serverTransmission :: ProtocolClient v err msg -> Bool -> RecipientId -> msg -> ServerTransmission v msg +serverTransmission ProtocolClient {thParams = THandleParams {thVersion, sessionId}, client_ = PClient {transportSession}} isResponse entityId message = + (transportSession, thVersion, sessionId, isResponse, entityId, message) -- | Get message from SMP queue. The server returns ERR PROHIBITED if a client uses SUB and GET via the same transport connection for the same queue -- diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index c93fe06e5..4b925c6f6 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -163,8 +163,8 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, msgQ, randomDrg, wo waitForSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient waitForSMPClient v = do - let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout, tcpTimeout}} = smpCfg agentCfg - smpClient_ <- liftIO $ (tcpConnectTimeout + tcpTimeout) `timeout` atomically (readTMVar $ sessionVar v) + let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout}} = smpCfg agentCfg + smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) liftEither $ case smpClient_ of Just (Right smpClient) -> Right smpClient Just (Left e) -> Left e diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index 55bdb07eb..b79665c87 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -218,7 +218,7 @@ ntfSubscriber NtfSubscriber {smpSubscribers, newSubQ, smpAgent = ca@SMPClientAge receiveSMP :: M () receiveSMP = forever $ do - ((_, srv, _), _, _, ntfId, msg) <- atomically $ readTBQueue msgQ + ((_, srv, _), _, _, _, ntfId, msg) <- atomically $ readTBQueue msgQ let smpQueue = SMPQueueNtf srv ntfId case msg of SMP.NMSG nmsgNonce encNMsgMeta -> do diff --git a/src/Simplex/Messaging/Transport/Client.hs b/src/Simplex/Messaging/Transport/Client.hs index 3d5188348..08cff1d0d 100644 --- a/src/Simplex/Messaging/Transport/Client.hs +++ b/src/Simplex/Messaging/Transport/Client.hs @@ -2,6 +2,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -124,7 +125,7 @@ data TransportClientConfig = TransportClientConfig -- time to resolve host, connect socket, set up TLS defaultTcpConnectTimeout :: Int -defaultTcpConnectTimeout = 10000000 +defaultTcpConnectTimeout = 25_000_000 defaultTransportClientConfig :: TransportClientConfig defaultTransportClientConfig = TransportClientConfig Nothing defaultTcpConnectTimeout (Just defaultKeepAliveOpts) True Nothing Nothing @@ -149,7 +150,7 @@ runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, sock <- connectTCP port mapM_ (setSocketKeepAlive sock) tcpKeepAlive `catchAll` \e -> logError ("Error setting TCP keep-alive" <> tshow e) let tCfg = clientTransportConfig cfg - timeout tcpConnectTimeout (connectTLS (Just hostName) tCfg clientParams sock) >>= \case + tcpConnectTimeout `timeout` connectTLS (Just hostName) tCfg clientParams sock >>= \case Nothing -> do close sock logError "connection timed out" diff --git a/src/Simplex/Messaging/Transport/HTTP2/Client.hs b/src/Simplex/Messaging/Transport/HTTP2/Client.hs index be37a6887..d701d4114 100644 --- a/src/Simplex/Messaging/Transport/HTTP2/Client.hs +++ b/src/Simplex/Messaging/Transport/HTTP2/Client.hs @@ -70,7 +70,7 @@ defaultHTTP2ClientConfig :: HTTP2ClientConfig defaultHTTP2ClientConfig = HTTP2ClientConfig { qSize = 64, - connTimeout = 10000000, + connTimeout = defaultTcpConnectTimeout, transportConfig = TransportClientConfig { socksProxy = Nothing, From b08314722d2c8f4d640dd5baba73c71e09ff0dda Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 21 Apr 2024 22:58:15 +0100 Subject: [PATCH 016/107] 5.7.0.1 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index f62e739d3..91b91c214 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.0.0 +version: 5.7.0.1 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 42cebccfa..797acdde0 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.0.0 +version: 5.7.0.1 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 1612a7e2c7475f4a8e0a037a55816097be3310a5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 22 Apr 2024 09:25:03 +0100 Subject: [PATCH 017/107] agent: reduce sizes for per-queue e2e encryption (#1113) --- src/Simplex/Messaging/Protocol.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index a52c36db0..1dc76dbab 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -238,10 +238,10 @@ type MaxRcvMessageLen = MaxMessageLen + 16 -- 16104, the padded size is 16106 -- it is shorter to allow per-queue e2e encryption DH key in the "public" header e2eEncConfirmationLength :: Int -e2eEncConfirmationLength = 15936 +e2eEncConfirmationLength = 15920 -- 15881 .. 15976 e2eEncMessageLength :: Int -e2eEncMessageLength = 16032 +e2eEncMessageLength = 16016 -- 16004 .. 16021 -- | SMP protocol clients data Party = Recipient | Sender | Notifier From fe28e02be779fb0357a7f650bd327a75f4aaab95 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 22 Apr 2024 13:40:24 +0100 Subject: [PATCH 018/107] agent: make version independent of PQ enqryption support (#1114) * agent: make version independent of PQ enqryption support * remove comment --- src/Simplex/Messaging/Agent.hs | 76 ++++++++++------------ src/Simplex/Messaging/Agent/Env/SQLite.hs | 6 +- src/Simplex/Messaging/Agent/Protocol.hs | 17 ++--- src/Simplex/Messaging/Crypto/Ratchet.hs | 11 +--- tests/AgentTests.hs | 41 +++++------- tests/AgentTests/ConnectionRequestTests.hs | 4 +- tests/AgentTests/DoubleRatchetTests.hs | 20 +++--- tests/AgentTests/FunctionalAPITests.hs | 65 +++++++++--------- 8 files changed, 106 insertions(+), 134 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 56a08dd01..38112b030 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -618,14 +618,14 @@ newConnAsync c userId corrId enableNtfs cMode pqInitKeys subMode = do newConnNoQueues :: AgentClient -> UserId -> ConnId -> Bool -> SConnectionMode c -> PQSupport -> AM ConnId newConnNoQueues c userId connId enableNtfs cMode pqSupport = do g <- asks random - connAgentVersion <- asks $ maxVersion . ($ pqSupport) . smpAgentVRange . config + connAgentVersion <- asks $ maxVersion . smpAgentVRange . config let cData = ConnData {userId, connId, connAgentVersion, enableNtfs, lastExternalSndId = 0, deleted = False, ratchetSyncState = RSOk, pqSupport} withStore c $ \db -> createNewConn db g cData cMode joinConnAsync :: AgentClient -> UserId -> ACorrId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> AM ConnId joinConnAsync c userId corrId enableNtfs cReqUri@CRInvitationUri {} cInfo pqSup subMode = do withInvLock c (strEncode cReqUri) "joinConnAsync" $ do - lift (compatibleInvitationUri cReqUri pqSup) >>= \case + lift (compatibleInvitationUri cReqUri) >>= \case Just (_, Compatible (CR.E2ERatchetParams v _ _ _), Compatible connAgentVersion) -> do g <- asks random let pqSupport = pqSup `CR.pqSupportAnd` versionPQSupport_ connAgentVersion (Just v) @@ -729,16 +729,14 @@ newRcvConnSrv c userId connId enableNtfs cMode clientData pqInitKeys subMode srv when enableNtfs $ do ns <- asks ntfSupervisor atomically $ sendNtfSubCommand ns (connId, NSCCreate) - let pqEnc = CR.connPQEncryption pqInitKeys - crData = ConnReqUriData SSSimplex (smpAgentVRange pqEnc) [qUri] clientData - e2eVRange = e2eEncryptVRange pqEnc + let crData = ConnReqUriData SSSimplex smpAgentVRange [qUri] clientData case cMode of SCMContact -> pure (connId, CRContactUri crData) SCMInvitation -> do g <- asks random - (pk1, pk2, pKem, e2eRcvParams) <- liftIO $ CR.generateRcvE2EParams g (maxVersion e2eVRange) (CR.initialPQEncryption pqInitKeys) + (pk1, pk2, pKem, e2eRcvParams) <- liftIO $ CR.generateRcvE2EParams g (maxVersion e2eEncryptVRange) (CR.initialPQEncryption pqInitKeys) withStore' c $ \db -> createRatchetX3dhKeys db connId pk1 pk2 pKem - pure (connId, CRInvitationUri crData $ toVersionRangeT e2eRcvParams e2eVRange) + pure (connId, CRInvitationUri crData $ toVersionRangeT e2eRcvParams e2eEncryptVRange) joinConn :: AgentClient -> UserId -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> AM ConnId joinConn c userId connId enableNtfs cReq cInfo pqSupport subMode = do @@ -750,14 +748,14 @@ joinConn c userId connId enableNtfs cReq cInfo pqSupport subMode = do startJoinInvitation :: UserId -> ConnId -> Bool -> ConnectionRequestUri 'CMInvitation -> PQSupport -> AM (Compatible VersionSMPA, ConnData, NewSndQueue, CR.Ratchet 'C.X448, CR.SndE2ERatchetParams 'C.X448) startJoinInvitation userId connId enableNtfs cReqUri pqSup = - lift (compatibleInvitationUri cReqUri pqSup) >>= \case + lift (compatibleInvitationUri cReqUri) >>= \case Just (qInfo, (Compatible e2eRcvParams@(CR.E2ERatchetParams v _ rcDHRr kem_)), aVersion@(Compatible connAgentVersion)) -> do g <- asks random let pqSupport = pqSup `CR.pqSupportAnd` versionPQSupport_ connAgentVersion (Just v) (pk1, pk2, pKem, e2eSndParams) <- liftIO $ CR.generateSndE2EParams g v (CR.replyKEM_ v kem_ pqSupport) (_, rcDHRs) <- atomically $ C.generateKeyPair g rcParams <- liftEitherWith cryptoError $ CR.pqX3dhSnd pk1 pk2 pKem e2eRcvParams - maxSupported <- asks $ maxVersion . ($ pqSup) . e2eEncryptVRange . config + maxSupported <- asks $ maxVersion . e2eEncryptVRange . config let rcVs = CR.RatchetVersions {current = v, maxSupported} rc = CR.initSndRatchet rcVs rcDHRr rcDHRs rcParams q <- lift $ newSndQueue userId "" qInfo @@ -767,29 +765,29 @@ startJoinInvitation userId connId enableNtfs cReqUri pqSup = connRequestPQSupport :: AgentClient -> PQSupport -> ConnectionRequestUri c -> IO (Maybe (VersionSMPA, PQSupport)) connRequestPQSupport c pqSup cReq = withAgentEnv' c $ case cReq of - CRInvitationUri {} -> invPQSupported <$$> compatibleInvitationUri cReq pqSup + CRInvitationUri {} -> invPQSupported <$$> compatibleInvitationUri cReq where invPQSupported (_, Compatible (CR.E2ERatchetParams e2eV _ _ _), Compatible agentV) = (agentV, pqSup `CR.pqSupportAnd` versionPQSupport_ agentV (Just e2eV)) - CRContactUri {} -> ctPQSupported <$$> compatibleContactUri cReq pqSup + CRContactUri {} -> ctPQSupported <$$> compatibleContactUri cReq where ctPQSupported (_, Compatible agentV) = (agentV, pqSup `CR.pqSupportAnd` versionPQSupport_ agentV Nothing) -compatibleInvitationUri :: ConnectionRequestUri 'CMInvitation -> PQSupport -> AM' (Maybe (Compatible SMPQueueInfo, Compatible (CR.RcvE2ERatchetParams 'C.X448), Compatible VersionSMPA)) -compatibleInvitationUri (CRInvitationUri ConnReqUriData {crAgentVRange, crSmpQueues = (qUri :| _)} e2eRcvParamsUri) pqSup = do +compatibleInvitationUri :: ConnectionRequestUri 'CMInvitation -> AM' (Maybe (Compatible SMPQueueInfo, Compatible (CR.RcvE2ERatchetParams 'C.X448), Compatible VersionSMPA)) +compatibleInvitationUri (CRInvitationUri ConnReqUriData {crAgentVRange, crSmpQueues = (qUri :| _)} e2eRcvParamsUri) = do AgentConfig {smpClientVRange, smpAgentVRange, e2eEncryptVRange} <- asks config pure $ (,,) <$> (qUri `compatibleVersion` smpClientVRange) - <*> (e2eRcvParamsUri `compatibleVersion` e2eEncryptVRange pqSup) - <*> (crAgentVRange `compatibleVersion` smpAgentVRange pqSup) + <*> (e2eRcvParamsUri `compatibleVersion` e2eEncryptVRange) + <*> (crAgentVRange `compatibleVersion` smpAgentVRange) -compatibleContactUri :: ConnectionRequestUri 'CMContact -> PQSupport -> AM' (Maybe (Compatible SMPQueueInfo, Compatible VersionSMPA)) -compatibleContactUri (CRContactUri ConnReqUriData {crAgentVRange, crSmpQueues = (qUri :| _)}) pqSup = do +compatibleContactUri :: ConnectionRequestUri 'CMContact -> AM' (Maybe (Compatible SMPQueueInfo, Compatible VersionSMPA)) +compatibleContactUri (CRContactUri ConnReqUriData {crAgentVRange, crSmpQueues = (qUri :| _)}) = do AgentConfig {smpClientVRange, smpAgentVRange} <- asks config pure $ (,) <$> (qUri `compatibleVersion` smpClientVRange) - <*> (crAgentVRange `compatibleVersion` smpAgentVRange pqSup) + <*> (crAgentVRange `compatibleVersion` smpAgentVRange) versionPQSupport_ :: VersionSMPA -> Maybe CR.VersionE2E -> PQSupport versionPQSupport_ agentV e2eV_ = PQSupport $ agentV >= pqdrSMPAgentVersion && maybe True (>= CR.pqRatchetE2EEncryptVersion) e2eV_ @@ -812,7 +810,7 @@ joinConnSrv c userId connId enableNtfs inv@CRInvitationUri {} cInfo pqSup subMod void $ withStore' c $ \db -> deleteConn db Nothing connId' throwError e joinConnSrv c userId connId enableNtfs cReqUri@CRContactUri {} cInfo pqSup subMode srv = - lift (compatibleContactUri cReqUri pqSup) >>= \case + lift (compatibleContactUri cReqUri) >>= \case Just (qInfo, vrsn) -> do (connId', cReq) <- newConnSrv c userId connId enableNtfs SCMInvitation Nothing (CR.IKNoPQ pqSup) subMode srv sendInvitation c userId qInfo vrsn cReq cInfo @@ -1219,7 +1217,7 @@ enqueueMessageB c reqs = do pure $ Right ((msgId, pqSecr), if null sqs' then Nothing else Just (cData, sqs', msgId)) where storeSentMsg :: DB.Connection -> AgentConfig -> (ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage) -> IO (Either AgentErrorType ((ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage), InternalId, PQEncryption)) - storeSentMsg db cfg req@(cData@ConnData {connId, pqSupport}, sq :| _, pqEnc_, msgFlags, aMessage) = fmap (first storeError) $ runExceptT $ do + storeSentMsg db cfg req@(cData@ConnData {connId}, sq :| _, pqEnc_, msgFlags, aMessage) = fmap (first storeError) $ runExceptT $ do let AgentConfig {smpAgentVRange, e2eEncryptVRange} = cfg internalTs <- liftIO getCurrentTime (internalId, internalSndId, prevMsgHash) <- liftIO $ updateSndIds db connId @@ -1227,10 +1225,9 @@ enqueueMessageB c reqs = do agentMsg = AgentMessage privHeader aMessage agentMsgStr = smpEncode agentMsg internalHash = C.sha256Hash agentMsgStr - currentE2EVersion = maxVersion $ e2eEncryptVRange PQSupportOff - (encAgentMessage, pqEnc) <- agentRatchetEncrypt db cData agentMsgStr e2eEncUserMsgLength pqEnc_ currentE2EVersion - -- agent version range is determined by the connection suppport of PQ encryption, that is may be enabled when message is sent - let agentVersion = maxVersion $ smpAgentVRange pqSupport + currentE2EVersion = maxVersion e2eEncryptVRange + (encAgentMessage, pqEnc) <- agentRatchetEncrypt db cData agentMsgStr e2eEncAgentMsgLength pqEnc_ currentE2EVersion + let agentVersion = maxVersion smpAgentVRange msgBody = smpEncode $ AgentMsgEnvelope {agentVersion, encAgentMessage} msgType = agentMessageType agentMsg msgData = SndMsgData {internalId, internalSndId, internalTs, msgType, msgFlags, msgBody, pqEncryption = pqEnc, internalHash, prevMsgHash} @@ -1520,7 +1517,7 @@ synchronizeRatchet' c connId pqSupport' force = withConnLock c connId "synchroni let cData' = cData {pqSupport = pqSupport'} :: ConnData AgentConfig {e2eEncryptVRange} <- asks config g <- asks random - (pk1, pk2, pKem, e2eParams) <- liftIO $ CR.generateRcvE2EParams g (maxVersion $ e2eEncryptVRange pqSupport') pqSupport' + (pk1, pk2, pKem, e2eParams) <- liftIO $ CR.generateRcvE2EParams g (maxVersion e2eEncryptVRange) pqSupport' enqueueRatchetKeyMsgs c cData' sqs e2eParams withStore' c $ \db -> do setConnRatchetSync db connId RSStarted @@ -2189,8 +2186,8 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, _ -> prohibited >> ack _ -> prohibited >> ack updateConnVersion :: Connection c -> ConnData -> VersionSMPA -> AM (Connection c) - updateConnVersion conn' cData'@ConnData {pqSupport} msgAgentVersion = do - aVRange <- asks $ ($ pqSupport) . smpAgentVRange . config + updateConnVersion conn' cData' msgAgentVersion = do + aVRange <- asks $ smpAgentVRange . config let msgAVRange = fromMaybe (versionToRange msgAgentVersion) $ safeVersionRange (minVersion aVRange) msgAgentVersion case msgAVRange `compatibleVersion` aVRange of Just (Compatible av) @@ -2256,19 +2253,17 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, logServer "<--" c srv rId $ "MSG :" <> logSecret srvMsgId AgentConfig {smpClientVRange, smpAgentVRange, e2eEncryptVRange} <- asks config let ConnData {pqSupport} = toConnData conn' - aVRange = smpAgentVRange pqSupport - e2eVRange = e2eEncryptVRange pqSupport unless - (agentVersion `isCompatible` aVRange && smpClientVersion `isCompatible` smpClientVRange) + (agentVersion `isCompatible` smpAgentVRange && smpClientVersion `isCompatible` smpClientVRange) (throwError $ AGENT A_VERSION) case status of New -> case (conn', e2eEncryption) of -- party initiating connection (RcvConnection _ _, Just (CR.AE2ERatchetParams _ e2eSndParams@(CR.E2ERatchetParams e2eVersion _ _ _))) -> do - unless (e2eVersion `isCompatible` e2eVRange) (throwError $ AGENT A_VERSION) + unless (e2eVersion `isCompatible` e2eEncryptVRange) (throwError $ AGENT A_VERSION) (pk1, rcDHRs, pKem) <- withStore c (`getRatchetX3dhKeys` connId) rcParams <- liftError cryptoError $ CR.pqX3dhRcv pk1 rcDHRs pKem e2eSndParams - let rcVs = CR.RatchetVersions {current = e2eVersion, maxSupported = maxVersion e2eVRange} + let rcVs = CR.RatchetVersions {current = e2eVersion, maxSupported = maxVersion e2eEncryptVRange} pqSupport' = pqSupport `CR.pqSupportAnd` versionPQSupport_ agentVersion (Just e2eVersion) rc = CR.initRcvRatchet rcVs rcDHRs rcParams pqSupport' g <- asks random @@ -2450,7 +2445,7 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, ContactConnection {} -> do -- show connection request even if invitaion via contact address is not compatible. -- in case invitation not compatible, assume there is no PQ encryption support. - pqSupport <- lift $ maybe PQSupportOff pqSupported <$> compatibleInvitationUri connReq PQSupportOn + pqSupport <- lift $ maybe PQSupportOff pqSupported <$> compatibleInvitationUri connReq g <- asks random let newInv = NewInvitation {contactConnId = connId, connReq, recipientConnInfo = cInfo} invId <- withStore c $ \db -> createInvitation db g newInv @@ -2470,10 +2465,9 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, newRatchetKey e2eOtherPartyParams@(CR.E2ERatchetParams e2eVersion k1Rcv k2Rcv _) conn'@(DuplexConnection cData'@ConnData {lastExternalSndId, pqSupport} _ sqs) = unlessM ratchetExists $ do AgentConfig {e2eEncryptVRange} <- asks config - let connE2EVRange = e2eEncryptVRange pqSupport - unless (e2eVersion `isCompatible` connE2EVRange) (throwError $ AGENT A_VERSION) + unless (e2eVersion `isCompatible` e2eEncryptVRange) (throwError $ AGENT A_VERSION) keys <- getSendRatchetKeys - let rcVs = CR.RatchetVersions {current = e2eVersion, maxSupported = maxVersion connE2EVRange} + let rcVs = CR.RatchetVersions {current = e2eVersion, maxSupported = maxVersion e2eEncryptVRange} initRatchet rcVs keys notifyAgreed where @@ -2579,8 +2573,7 @@ confirmQueue (Compatible agentVersion) c cData@ConnData {connId, pqSupport} sq s where mkConfirmation :: AgentMessage -> AM MsgBody mkConfirmation aMessage = do - -- the version to be used when PQSupport is disabled - currentE2EVersion <- asks $ maxVersion . ($ PQSupportOff) . e2eEncryptVRange . config + currentE2EVersion <- asks $ maxVersion . e2eEncryptVRange . config withStore c $ \db -> runExceptT $ do void . liftIO $ updateSndIds db connId let pqEnc = CR.pqSupportToEnc pqSupport @@ -2599,8 +2592,7 @@ enqueueConfirmation c cData sq connInfo e2eEncryption_ = do storeConfirmation :: AgentClient -> ConnData -> SndQueue -> Maybe (CR.SndE2ERatchetParams 'C.X448) -> AgentMessage -> AM () storeConfirmation c cData@ConnData {connId, pqSupport, connAgentVersion = v} sq e2eEncryption_ agentMsg = do - -- the version to be used when PQSupport is disabled - currentE2EVersion <- asks $ maxVersion . ($ PQSupportOff) . e2eEncryptVRange . config + currentE2EVersion <- asks $ maxVersion . e2eEncryptVRange . config withStore c $ \db -> runExceptT $ do internalTs <- liftIO getCurrentTime (internalId, internalSndId, prevMsgHash) <- liftIO $ updateSndIds db connId @@ -2620,8 +2612,8 @@ enqueueRatchetKeyMsgs c cData (sq :| sqs) e2eEncryption = do mapM_ (lift . enqueueSavedMessage c cData msgId) $ filter isActiveSndQ sqs enqueueRatchetKey :: AgentClient -> ConnData -> SndQueue -> CR.RcvE2ERatchetParams 'C.X448 -> AM AgentMsgId -enqueueRatchetKey c cData@ConnData {connId, pqSupport} sq e2eEncryption = do - aVRange <- asks $ ($ pqSupport) . smpAgentVRange . config +enqueueRatchetKey c cData@ConnData {connId} sq e2eEncryption = do + aVRange <- asks $ smpAgentVRange . config msgId <- storeRatchetKey $ maxVersion aVRange lift $ submitPendingMsg c cData sq pure $ unId msgId diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index 02a28ba95..f91144fdc 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -56,7 +56,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (PQSupport, VersionRangeE2E, supportedE2EEncryptVRange) +import Simplex.Messaging.Crypto.Ratchet (VersionRangeE2E, supportedE2EEncryptVRange) import Simplex.Messaging.Notifications.Client (defaultNTFClientConfig) import Simplex.Messaging.Notifications.Transport (NTFVersion) import Simplex.Messaging.Notifications.Types @@ -117,8 +117,8 @@ data AgentConfig = AgentConfig caCertificateFile :: FilePath, privateKeyFile :: FilePath, certificateFile :: FilePath, - e2eEncryptVRange :: PQSupport -> VersionRangeE2E, - smpAgentVRange :: PQSupport -> VersionRangeSMPA, + e2eEncryptVRange :: VersionRangeE2E, + smpAgentVRange :: VersionRangeSMPA, smpClientVRange :: VersionRangeSMPC } diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 4ee8d373f..98db26ab4 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -44,7 +44,7 @@ module Simplex.Messaging.Agent.Protocol currentSMPAgentVersion, supportedSMPAgentVRange, e2eEncConnInfoLength, - e2eEncUserMsgLength, + e2eEncAgentMsgLength, -- * SMP agent protocol types ConnInfo, @@ -272,16 +272,11 @@ deliveryRcptsSMPAgentVersion = VersionSMPA 4 pqdrSMPAgentVersion :: VersionSMPA pqdrSMPAgentVersion = VersionSMPA 5 --- TODO v5.7 increase to 5 currentSMPAgentVersion :: VersionSMPA -currentSMPAgentVersion = VersionSMPA 4 +currentSMPAgentVersion = VersionSMPA 5 --- TODO v5.7 remove dependency of version range on whether PQ support is needed -supportedSMPAgentVRange :: PQSupport -> VersionRangeSMPA -supportedSMPAgentVRange pq = - mkVersionRange duplexHandshakeSMPAgentVersion $ case pq of - PQSupportOn -> pqdrSMPAgentVersion - PQSupportOff -> currentSMPAgentVersion +supportedSMPAgentVRange :: VersionRangeSMPA +supportedSMPAgentVRange = mkVersionRange duplexHandshakeSMPAgentVersion currentSMPAgentVersion -- it is shorter to allow all handshake headers, -- including E2E (double-ratchet) parameters and @@ -292,8 +287,8 @@ e2eEncConnInfoLength v = \case PQSupportOn | v >= pqdrSMPAgentVersion -> 11122 _ -> 14848 -e2eEncUserMsgLength :: VersionSMPA -> PQSupport -> Int -e2eEncUserMsgLength v = \case +e2eEncAgentMsgLength :: VersionSMPA -> PQSupport -> Int +e2eEncAgentMsgLength v = \case -- reduced by 2222 (the increase of message ratchet header size) PQSupportOn | v >= pqdrSMPAgentVersion -> 13634 _ -> 15856 diff --git a/src/Simplex/Messaging/Crypto/Ratchet.hs b/src/Simplex/Messaging/Crypto/Ratchet.hs index 068f62776..6ab84aa30 100644 --- a/src/Simplex/Messaging/Crypto/Ratchet.hs +++ b/src/Simplex/Messaging/Crypto/Ratchet.hs @@ -143,16 +143,11 @@ kdfX3DHE2EEncryptVersion = VersionE2E 2 pqRatchetE2EEncryptVersion :: VersionE2E pqRatchetE2EEncryptVersion = VersionE2E 3 --- TODO v5.7 increase to 3 currentE2EEncryptVersion :: VersionE2E -currentE2EEncryptVersion = VersionE2E 2 +currentE2EEncryptVersion = VersionE2E 3 --- TODO v5.7 remove dependency of version range on whether PQ encryption is used -supportedE2EEncryptVRange :: PQSupport -> VersionRangeE2E -supportedE2EEncryptVRange pq = - mkVersionRange kdfX3DHE2EEncryptVersion $ case pq of - PQSupportOn -> pqRatchetE2EEncryptVersion - PQSupportOff -> currentE2EEncryptVersion +supportedE2EEncryptVRange :: VersionRangeE2E +supportedE2EEncryptVRange = mkVersionRange kdfX3DHE2EEncryptVersion currentE2EEncryptVersion data RatchetKEMState = RKSProposed -- only KEM encapsulation key diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index 8083ef988..b890c2c00 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -214,14 +214,13 @@ testDuplexConnection _ alice bob = testDuplexConnection' (alice, IKPQOn) (bob, P testDuplexConnection' :: (HasCallStack, Transport c) => (c, InitialKeys) -> (c, PQSupport) -> IO () testDuplexConnection' (alice, aPQ) (bob, bPQ) = do let pq = pqConnectionMode aPQ bPQ - pqSup = CR.pqEncToSupport pq ("1", "bob", Right (INV cReq)) <- alice #: ("1", "bob", "NEW T INV" <> pqConnModeStr aPQ <> " subscribe") let cReq' = strEncode cReq bob #: ("11", "alice", "JOIN T " <> cReq' <> enableKEMStr bPQ <> " subscribe 14\nbob's connInfo") #> ("11", "alice", OK) ("", "bob", Right (A.CONF confId pqSup' _ "bob's connInfo")) <- (alice <#:) - pqSup' `shouldBe` pqSup + pqSup' `shouldBe` CR.connPQEncryption aPQ alice #: ("2", "bob", "LET " <> confId <> " 16\nalice's connInfo") #> ("2", "bob", OK) - bob <# ("", "alice", A.INFO pqSup "alice's connInfo") + bob <# ("", "alice", A.INFO bPQ "alice's connInfo") bob <# ("", "alice", CON pq) alice <# ("", "bob", CON pq) -- message IDs 1 to 3 get assigned to control messages, so first MSG is assigned ID 4 @@ -253,15 +252,14 @@ testDuplexConnRandomIds _ alice bob = testDuplexConnRandomIds' (alice, IKPQOn) ( testDuplexConnRandomIds' :: (HasCallStack, Transport c) => (c, InitialKeys) -> (c, PQSupport) -> IO () testDuplexConnRandomIds' (alice, aPQ) (bob, bPQ) = do let pq = pqConnectionMode aPQ bPQ - pqSup = CR.pqEncToSupport pq ("1", bobConn, Right (INV cReq)) <- alice #: ("1", "", "NEW T INV" <> pqConnModeStr aPQ <> " subscribe") let cReq' = strEncode cReq ("11", aliceConn, Right OK) <- bob #: ("11", "", "JOIN T " <> cReq' <> enableKEMStr bPQ <> " subscribe 14\nbob's connInfo") ("", bobConn', Right (A.CONF confId pqSup' _ "bob's connInfo")) <- (alice <#:) - pqSup' `shouldBe` pqSup + pqSup' `shouldBe` CR.connPQEncryption aPQ bobConn' `shouldBe` bobConn alice #: ("2", bobConn, "LET " <> confId <> " 16\nalice's connInfo") =#> \case ("2", c, OK) -> c == bobConn; _ -> False - bob <# ("", aliceConn, A.INFO pqSup "alice's connInfo") + bob <# ("", aliceConn, A.INFO bPQ "alice's connInfo") bob <# ("", aliceConn, CON pq) alice <# ("", bobConn, CON pq) alice #: ("2", bobConn, "SEND F :hello") #> ("2", bobConn, A.MID 4 pq) @@ -291,17 +289,15 @@ testContactConnection (alice, aPQ) (bob, bPQ) (tom, tPQ) = do ("1", "alice_contact", Right (INV cReq)) <- alice #: ("1", "alice_contact", "NEW T CON" <> pqConnModeStr aPQ <> " subscribe") let cReq' = strEncode cReq abPQ = pqConnectionMode aPQ bPQ - abPQSup = CR.pqEncToSupport abPQ aPQMode = CR.connPQEncryption aPQ bob #: ("11", "alice", "JOIN T " <> cReq' <> enableKEMStr bPQ <> " subscribe 14\nbob's connInfo") #> ("11", "alice", OK) - ("", "alice_contact", Right (A.REQ aInvId pqSup' _ "bob's connInfo")) <- (alice <#:) - pqSup' `shouldBe` bPQ + ("", "alice_contact", Right (A.REQ aInvId PQSupportOn _ "bob's connInfo")) <- (alice <#:) alice #: ("2", "bob", "ACPT " <> aInvId <> enableKEMStr aPQMode <> " 16\nalice's connInfo") #> ("2", "bob", OK) ("", "alice", Right (A.CONF bConfId pqSup'' _ "alice's connInfo")) <- (bob <#:) - pqSup'' `shouldBe` abPQSup + pqSup'' `shouldBe` bPQ bob #: ("12", "alice", "LET " <> bConfId <> " 16\nbob's connInfo 2") #> ("12", "alice", OK) - alice <# ("", "bob", A.INFO abPQSup "bob's connInfo 2") + alice <# ("", "bob", A.INFO (CR.connPQEncryption aPQ) "bob's connInfo 2") alice <# ("", "bob", CON abPQ) bob <# ("", "alice", CON abPQ) alice #: ("3", "bob", "SEND F :hi") #> ("3", "bob", A.MID 4 abPQ) @@ -310,15 +306,13 @@ testContactConnection (alice, aPQ) (bob, bPQ) (tom, tPQ) = do bob #: ("13", "alice", "ACK 4") #> ("13", "alice", OK) let atPQ = pqConnectionMode aPQ tPQ - atPQSup = CR.pqEncToSupport atPQ tom #: ("21", "alice", "JOIN T " <> cReq' <> enableKEMStr tPQ <> " subscribe 14\ntom's connInfo") #> ("21", "alice", OK) - ("", "alice_contact", Right (A.REQ aInvId' pqSup3 _ "tom's connInfo")) <- (alice <#:) - pqSup3 `shouldBe` tPQ + ("", "alice_contact", Right (A.REQ aInvId' PQSupportOn _ "tom's connInfo")) <- (alice <#:) alice #: ("4", "tom", "ACPT " <> aInvId' <> enableKEMStr aPQMode <> " 16\nalice's connInfo") #> ("4", "tom", OK) ("", "alice", Right (A.CONF tConfId pqSup4 _ "alice's connInfo")) <- (tom <#:) - pqSup4 `shouldBe` atPQSup + pqSup4 `shouldBe` tPQ tom #: ("22", "alice", "LET " <> tConfId <> " 16\ntom's connInfo 2") #> ("22", "alice", OK) - alice <# ("", "tom", A.INFO atPQSup "tom's connInfo 2") + alice <# ("", "tom", A.INFO (CR.connPQEncryption aPQ) "tom's connInfo 2") alice <# ("", "tom", CON atPQ) tom <# ("", "alice", CON atPQ) alice #: ("5", "tom", "SEND F :hi there") #> ("5", "tom", A.MID 4 atPQ) @@ -329,22 +323,20 @@ testContactConnection (alice, aPQ) (bob, bPQ) (tom, tPQ) = do testContactConnRandomIds :: Transport c => (c, InitialKeys) -> (c, PQSupport) -> IO () testContactConnRandomIds (alice, aPQ) (bob, bPQ) = do let pq = pqConnectionMode aPQ bPQ - pqSup = CR.pqEncToSupport pq ("1", aliceContact, Right (INV cReq)) <- alice #: ("1", "", "NEW T CON" <> pqConnModeStr aPQ <> " subscribe") let cReq' = strEncode cReq ("11", aliceConn, Right OK) <- bob #: ("11", "", "JOIN T " <> cReq' <> enableKEMStr bPQ <> " subscribe 14\nbob's connInfo") - ("", aliceContact', Right (A.REQ aInvId pqSup' _ "bob's connInfo")) <- (alice <#:) - pqSup' `shouldBe` bPQ + ("", aliceContact', Right (A.REQ aInvId PQSupportOn _ "bob's connInfo")) <- (alice <#:) aliceContact' `shouldBe` aliceContact ("2", bobConn, Right OK) <- alice #: ("2", "", "ACPT " <> aInvId <> enableKEMStr (CR.connPQEncryption aPQ) <> " 16\nalice's connInfo") ("", aliceConn', Right (A.CONF bConfId pqSup'' _ "alice's connInfo")) <- (bob <#:) - pqSup'' `shouldBe` pqSup + pqSup'' `shouldBe` bPQ aliceConn' `shouldBe` aliceConn bob #: ("12", aliceConn, "LET " <> bConfId <> " 16\nbob's connInfo 2") #> ("12", aliceConn, OK) - alice <# ("", bobConn, A.INFO pqSup "bob's connInfo 2") + alice <# ("", bobConn, A.INFO (CR.connPQEncryption aPQ) "bob's connInfo 2") alice <# ("", bobConn, CON pq) bob <# ("", aliceConn, CON pq) @@ -358,7 +350,7 @@ testRejectContactRequest _ alice bob = do ("1", "a_contact", Right (INV cReq)) <- alice #: ("1", "a_contact", "NEW T CON subscribe") let cReq' = strEncode cReq bob #: ("11", "alice", "JOIN T " <> cReq' <> " subscribe 10\nbob's info") #> ("11", "alice", OK) - ("", "a_contact", Right (A.REQ aInvId PQSupportOff _ "bob's info")) <- (alice <#:) + ("", "a_contact", Right (A.REQ aInvId PQSupportOn _ "bob's info")) <- (alice <#:) -- RJCT must use correct contact connection alice #: ("2a", "bob", "RJCT " <> aInvId) #> ("2a", "bob", ERR $ CONN NOT_FOUND) alice #: ("2b", "a_contact", "RJCT " <> aInvId) #> ("2b", "a_contact", OK) @@ -571,12 +563,11 @@ connect' (h1, name1, pqMode1) (h2, name2, pqMode2) = do ("c1", _, Right (INV cReq)) <- h1 #: ("c1", name2, "NEW T INV" <> pqConnModeStr pqMode1 <> " subscribe") let cReq' = strEncode cReq pq = pqConnectionMode pqMode1 pqMode2 - pqSup = CR.pqEncToSupport pq h2 #: ("c2", name1, "JOIN T " <> cReq' <> enableKEMStr pqMode2 <> " subscribe 5\ninfo2") #> ("c2", name1, OK) ("", _, Right (A.CONF connId pqSup' _ "info2")) <- (h1 <#:) - pqSup' `shouldBe` pqSup + pqSup' `shouldBe` CR.connPQEncryption pqMode1 h1 #: ("c3", name2, "LET " <> connId <> " 5\ninfo1") #> ("c3", name2, OK) - h2 <# ("", name1, A.INFO pqSup "info1") + h2 <# ("", name1, A.INFO pqMode2 "info1") h2 <# ("", name1, CON pq) h1 <# ("", name2, CON pq) diff --git a/tests/AgentTests/ConnectionRequestTests.hs b/tests/AgentTests/ConnectionRequestTests.hs index 7ab234887..20480f84c 100644 --- a/tests/AgentTests/ConnectionRequestTests.hs +++ b/tests/AgentTests/ConnectionRequestTests.hs @@ -68,7 +68,7 @@ testE2ERatchetParams :: RcvE2ERatchetParamsUri 'C.X448 testE2ERatchetParams = E2ERatchetParamsUri (mkVersionRange (VersionE2E 1) (VersionE2E 1)) testDhPubKey testDhPubKey Nothing testE2ERatchetParams12 :: RcvE2ERatchetParamsUri 'C.X448 -testE2ERatchetParams12 = E2ERatchetParamsUri (supportedE2EEncryptVRange PQSupportOn) testDhPubKey testDhPubKey Nothing +testE2ERatchetParams12 = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKey testDhPubKey Nothing connectionRequest :: AConnectionRequestUri connectionRequest = @@ -82,7 +82,7 @@ connectionRequestCurrentRange :: AConnectionRequestUri connectionRequestCurrentRange = ACR SCMInvitation $ CRInvitationUri - connReqData {crAgentVRange = supportedSMPAgentVRange PQSupportOn, crSmpQueues = [queueV1, queueV1]} + connReqData {crAgentVRange = supportedSMPAgentVRange, crSmpQueues = [queueV1, queueV1]} testE2ERatchetParams12 connectionRequestClientDataEmpty :: AConnectionRequestUri diff --git a/tests/AgentTests/DoubleRatchetTests.hs b/tests/AgentTests/DoubleRatchetTests.hs index f95f07029..c3fbf01e8 100644 --- a/tests/AgentTests/DoubleRatchetTests.hs +++ b/tests/AgentTests/DoubleRatchetTests.hs @@ -93,9 +93,9 @@ fullMsgLen :: Ratchet a -> Int fullMsgLen Ratchet {rcSupportKEM, rcVersion} = headerLenLength + fullHeaderLen v rcSupportKEM + C.authTagSize + paddedMsgLen where v = current rcVersion - headerLenLength = case rcSupportKEM of - PQSupportOn | v >= pqRatchetE2EEncryptVersion -> 3 -- two bytes are added because of two Large used in new encoding - _ -> 1 + headerLenLength + | v >= pqRatchetE2EEncryptVersion = 3 -- two bytes are added because of two Large used in new encoding + | otherwise = 1 testMessageHeader :: forall a. AlgorithmI a => VersionE2E -> C.SAlgorithm a -> Expectation testMessageHeader v _ = do @@ -520,7 +520,7 @@ initRatchets = do Right paramsBob <- pure $ pqX3dhSnd pkBob1 pkBob2 Nothing e2eAlice Right paramsAlice <- runExceptT $ pqX3dhRcv pkAlice1 pkAlice2 Nothing e2eBob (_, pkBob3) <- atomically $ C.generateKeyPair g - let vs = testRatchetVersions PQSupportOff + let vs = testRatchetVersions bob = initSndRatchet vs (C.publicKey pkAlice2) pkBob3 paramsBob alice = initRcvRatchet vs pkAlice2 paramsAlice PQSupportOff pure (alice, bob, encrypt' noSndKEM, decrypt' noRcvKEM, (\#>)) @@ -537,7 +537,7 @@ initRatchetsKEMProposed = do Right paramsBob <- pure $ pqX3dhSnd pkBob1 pkBob2 pKemParams_ e2eAlice Right paramsAlice <- runExceptT $ pqX3dhRcv pkAlice1 pkAlice2 Nothing e2eBob (_, pkBob3) <- atomically $ C.generateKeyPair g - let vs = testRatchetVersions PQSupportOn + let vs = testRatchetVersions bob = initSndRatchet vs (C.publicKey pkAlice2) pkBob3 paramsBob alice = initRcvRatchet vs pkAlice2 paramsAlice PQSupportOn pure (alice, bob, encrypt' hasSndKEM, decrypt' hasRcvKEM, (!#>)) @@ -555,7 +555,7 @@ initRatchetsKEMAccepted = do Right paramsBob <- pure $ pqX3dhSnd pkBob1 pkBob2 pKemParams_ e2eAlice Right paramsAlice <- runExceptT $ pqX3dhRcv pkAlice1 pkAlice2 pKem_ e2eBob (_, pkBob3) <- atomically $ C.generateKeyPair g - let vs = testRatchetVersions PQSupportOn + let vs = testRatchetVersions bob = initSndRatchet vs (C.publicKey pkAlice2) pkBob3 paramsBob alice = initRcvRatchet vs pkAlice2 paramsAlice PQSupportOn pure (alice, bob, encrypt' hasSndKEM, decrypt' hasRcvKEM, (!#>)) @@ -572,14 +572,14 @@ initRatchetsKEMProposedAgain = do Right paramsBob <- pure $ pqX3dhSnd pkBob1 pkBob2 pKemParams_ e2eAlice Right paramsAlice <- runExceptT $ pqX3dhRcv pkAlice1 pkAlice2 pKem_ e2eBob (_, pkBob3) <- atomically $ C.generateKeyPair g - let vs = testRatchetVersions PQSupportOn + let vs = testRatchetVersions bob = initSndRatchet vs (C.publicKey pkAlice2) pkBob3 paramsBob alice = initRcvRatchet vs pkAlice2 paramsAlice PQSupportOn pure (alice, bob, encrypt' hasSndKEM, decrypt' hasRcvKEM, (!#>)) -testRatchetVersions :: PQSupport -> RatchetVersions -testRatchetVersions pq = - let v = maxVersion $ supportedE2EEncryptVRange pq +testRatchetVersions :: RatchetVersions +testRatchetVersions = + let v = maxVersion supportedE2EEncryptVRange in RatchetVersions v v encrypt_ :: AlgorithmI a => Maybe PQEncryption -> (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> ByteString -> IO (Either CryptoError (ByteString, Ratchet a, SkippedMsgDiff)) diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 301be97b4..79742efab 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -187,9 +187,9 @@ agentCfgVPrev :: AgentConfig agentCfgVPrev = agentCfg { sndAuthAlg = C.AuthAlg C.SEd25519, - smpAgentVRange = \_ -> prevRange $ smpAgentVRange agentCfg PQSupportOff, + smpAgentVRange = prevRange $ smpAgentVRange agentCfg, smpClientVRange = prevRange $ smpClientVRange agentCfg, - e2eEncryptVRange = \_ -> prevRange $ e2eEncryptVRange agentCfg PQSupportOff, + e2eEncryptVRange = prevRange $ e2eEncryptVRange agentCfg, smpCfg = smpCfgVPrev } @@ -198,14 +198,14 @@ agentCfgV7 :: AgentConfig agentCfgV7 = agentCfg { sndAuthAlg = C.AuthAlg C.SX25519, - smpAgentVRange = \_ -> V.mkVersionRange duplexHandshakeSMPAgentVersion $ max pqdrSMPAgentVersion currentSMPAgentVersion, - e2eEncryptVRange = \_ -> V.mkVersionRange CR.kdfX3DHE2EEncryptVersion $ max CR.pqRatchetE2EEncryptVersion CR.currentE2EEncryptVersion, + smpAgentVRange = V.mkVersionRange duplexHandshakeSMPAgentVersion $ max pqdrSMPAgentVersion currentSMPAgentVersion, + e2eEncryptVRange = V.mkVersionRange CR.kdfX3DHE2EEncryptVersion $ max CR.pqRatchetE2EEncryptVersion CR.currentE2EEncryptVersion, smpCfg = smpCfgV7, ntfCfg = ntfCfgV2 } agentCfgRatchetVPrev :: AgentConfig -agentCfgRatchetVPrev = agentCfg {e2eEncryptVRange = \_ -> prevRange $ e2eEncryptVRange agentCfg PQSupportOff} +agentCfgRatchetVPrev = agentCfg {e2eEncryptVRange = prevRange $ e2eEncryptVRange agentCfg} prevRange :: VersionRange v -> VersionRange v prevRange vr = vr {maxVersion = max (minVersion vr) (prevVersion $ maxVersion vr)} @@ -546,28 +546,27 @@ testEnablePQEncryption = (a, 4, "msg 1") \#>\ b (b, 5, "msg 2") \#>\ a -- 45 bytes is used by agent message envelope inside double ratchet message envelope - let largeMsg g' pqEnc = atomically $ C.randomBytes (e2eEncUserMsgLength pqdrSMPAgentVersion pqEnc - 45) g' + let largeMsg g' pqEnc = atomically $ C.randomBytes (e2eEncAgentMsgLength pqdrSMPAgentVersion pqEnc - 45) g' lrg <- largeMsg g PQSupportOff (a, 6, lrg) \#>\ b (b, 7, lrg) \#>\ a - -- enabling PQ encryption - (a, 8, lrg) \#>! b - (b, 9, lrg) \#>! a -- switched to smaller envelopes (before reporting PQ encryption enabled) sml <- largeMsg g PQSupportOn -- fail because of message size Left (A.CMD LARGE) <- tryError $ A.sendMessage ca bId PQEncOn SMP.noMsgFlags lrg - (11, PQEncOff) <- A.sendMessage ca bId PQEncOn SMP.noMsgFlags sml - get ca =##> \case ("", connId, SENT 11) -> connId == bId; _ -> False - get cb =##> \case ("", connId, MsgErr' 10 MsgSkipped {} PQEncOff msg') -> connId == aId && msg' == sml; _ -> False - ackMessage cb aId 10 Nothing + (9, PQEncOff) <- A.sendMessage ca bId PQEncOn SMP.noMsgFlags sml + get ca =##> \case ("", connId, SENT 9) -> connId == bId; _ -> False + get cb =##> \case ("", connId, MsgErr' 8 MsgSkipped {} PQEncOff msg') -> connId == aId && msg' == sml; _ -> False + ackMessage cb aId 8 Nothing -- -- fail in reply to sync IDss Left (A.CMD LARGE) <- tryError $ A.sendMessage cb aId PQEncOn SMP.noMsgFlags lrg - (12, PQEncOn) <- A.sendMessage cb aId PQEncOn SMP.noMsgFlags sml - get cb =##> \case ("", connId, SENT 12) -> connId == aId; _ -> False - get ca =##> \case ("", connId, MsgErr' 12 MsgSkipped {} PQEncOn msg') -> connId == bId && msg' == sml; _ -> False - ackMessage ca bId 12 Nothing + (10, PQEncOff) <- A.sendMessage cb aId PQEncOn SMP.noMsgFlags sml + get cb =##> \case ("", connId, SENT 10) -> connId == aId; _ -> False + get ca =##> \case ("", connId, MsgErr' 10 MsgSkipped {} PQEncOff msg') -> connId == bId && msg' == sml; _ -> False + ackMessage ca bId 10 Nothing + (a, 11, sml) \#>! b -- PQ encryption now enabled + (b, 12, sml) !#>! a (a, 13, sml) !#>! b (b, 14, sml) !#>! a -- disabling PQ encryption @@ -797,8 +796,8 @@ testAllowConnectionClientRestart t = do testIncreaseConnAgentVersion :: HasCallStack => ATransport -> IO () testIncreaseConnAgentVersion t = do - alice <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 2} initAgentServers testDB - bob <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 2} initAgentServers testDB2 + alice <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = mkVersionRange 1 2} initAgentServers testDB + bob <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = mkVersionRange 1 2} initAgentServers testDB2 withSmpServerStoreMsgLogOn t testPort $ \_ -> do (aliceId, bobId) <- runRight $ do (aliceId, bobId) <- makeConnection_ PQSupportOff alice bob @@ -810,7 +809,7 @@ testIncreaseConnAgentVersion t = do -- version doesn't increase if incompatible disposeAgentClient alice - alice2 <- getSMPAgentClient' 3 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB + alice2 <- getSMPAgentClient' 3 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB runRight_ $ do subscribeConnection alice2 bobId @@ -821,7 +820,7 @@ testIncreaseConnAgentVersion t = do -- version increases if compatible disposeAgentClient bob - bob2 <- getSMPAgentClient' 4 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB2 + bob2 <- getSMPAgentClient' 4 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB2 runRight_ $ do subscribeConnection bob2 aliceId @@ -832,7 +831,7 @@ testIncreaseConnAgentVersion t = do -- version doesn't decrease, even if incompatible disposeAgentClient alice2 - alice3 <- getSMPAgentClient' 5 agentCfg {smpAgentVRange = \_ -> mkVersionRange 2 2} initAgentServers testDB + alice3 <- getSMPAgentClient' 5 agentCfg {smpAgentVRange = mkVersionRange 2 2} initAgentServers testDB runRight_ $ do subscribeConnection alice3 bobId @@ -841,7 +840,7 @@ testIncreaseConnAgentVersion t = do checkVersion bob2 aliceId 3 disposeAgentClient bob2 - bob3 <- getSMPAgentClient' 6 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 1} initAgentServers testDB2 + bob3 <- getSMPAgentClient' 6 agentCfg {smpAgentVRange = mkVersionRange 1 1} initAgentServers testDB2 runRight_ $ do subscribeConnection bob3 aliceId @@ -858,8 +857,8 @@ checkVersion c connId v = do testIncreaseConnAgentVersionMaxCompatible :: HasCallStack => ATransport -> IO () testIncreaseConnAgentVersionMaxCompatible t = do - alice <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 2} initAgentServers testDB - bob <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 2} initAgentServers testDB2 + alice <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = mkVersionRange 1 2} initAgentServers testDB + bob <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = mkVersionRange 1 2} initAgentServers testDB2 withSmpServerStoreMsgLogOn t testPort $ \_ -> do (aliceId, bobId) <- runRight $ do (aliceId, bobId) <- makeConnection_ PQSupportOff alice bob @@ -871,7 +870,7 @@ testIncreaseConnAgentVersionMaxCompatible t = do -- version increases to max compatible disposeAgentClient alice - alice2 <- getSMPAgentClient' 3 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB + alice2 <- getSMPAgentClient' 3 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB disposeAgentClient bob bob2 <- getSMPAgentClient' 4 agentCfg {smpAgentVRange = supportedSMPAgentVRange} initAgentServers testDB2 @@ -886,8 +885,8 @@ testIncreaseConnAgentVersionMaxCompatible t = do testIncreaseConnAgentVersionStartDifferentVersion :: HasCallStack => ATransport -> IO () testIncreaseConnAgentVersionStartDifferentVersion t = do - alice <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 2} initAgentServers testDB - bob <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB2 + alice <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = mkVersionRange 1 2} initAgentServers testDB + bob <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB2 withSmpServerStoreMsgLogOn t testPort $ \_ -> do (aliceId, bobId) <- runRight $ do (aliceId, bobId) <- makeConnection_ PQSupportOff alice bob @@ -899,7 +898,7 @@ testIncreaseConnAgentVersionStartDifferentVersion t = do -- version increases to max compatible disposeAgentClient alice - alice2 <- getSMPAgentClient' 3 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB + alice2 <- getSMPAgentClient' 3 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB runRight_ $ do subscribeConnection alice2 bobId @@ -2454,8 +2453,8 @@ testDeliveryReceipts = testDeliveryReceiptsVersion :: HasCallStack => ATransport -> IO () testDeliveryReceiptsVersion t = do - a <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB - b <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = \_ -> mkVersionRange 1 3} initAgentServers testDB2 + a <- getSMPAgentClient' 1 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB + b <- getSMPAgentClient' 2 agentCfg {smpAgentVRange = mkVersionRange 1 3} initAgentServers testDB2 withSmpServerStoreMsgLogOn t testPort $ \_ -> do (aId, bId) <- runRight $ do (aId, bId) <- makeConnection_ PQSupportOff a b @@ -2482,8 +2481,8 @@ testDeliveryReceiptsVersion t = do subscribeConnection a' bId subscribeConnection b' aId exchangeGreetingsMsgId_ PQEncOff 6 a' bId b' aId - checkVersion a' bId 4 - checkVersion b' aId 4 + checkVersion a' bId 5 + checkVersion b' aId 5 (8, PQEncOff) <- A.sendMessage a' bId PQEncOn SMP.noMsgFlags "hello" get a' ##> ("", bId, SENT 8) get b' =##> \case ("", c, Msg' 8 PQEncOff "hello") -> c == aId; _ -> False From daa866f3331cf882f1030ae636f9e27a2498ca8b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 22 Apr 2024 18:59:33 +0100 Subject: [PATCH 019/107] 5.7.0.2 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 91b91c214..a547609a1 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.0.1 +version: 5.7.0.2 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 797acdde0..b48ad7d27 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.0.1 +version: 5.7.0.2 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From b931c1be3721f002747742d1e100c566966523f7 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 25 Apr 2024 11:33:22 +0300 Subject: [PATCH 020/107] utils: remove zstd contexts (#1116) --- src/Simplex/Messaging/Compression.hs | 59 ++++------------------------ 1 file changed, 7 insertions(+), 52 deletions(-) diff --git a/src/Simplex/Messaging/Compression.hs b/src/Simplex/Messaging/Compression.hs index 339107bea..19d91a300 100644 --- a/src/Simplex/Messaging/Compression.hs +++ b/src/Simplex/Messaging/Compression.hs @@ -4,20 +4,9 @@ module Simplex.Messaging.Compression where import qualified Codec.Compression.Zstd as Z1 -import qualified Codec.Compression.Zstd.FFI as Z -import Control.Monad (forM) -import Control.Monad.Except -import Control.Monad.IO.Class import Data.ByteString (ByteString) import qualified Data.ByteString as B -import qualified Data.ByteString.Unsafe as B -import Data.Either (fromRight) -import Data.List.NonEmpty (NonEmpty) -import Foreign -import Foreign.C.Types -import GHC.IO (unsafePerformIO) import Simplex.Messaging.Encoding -import UnliftIO.Exception (bracket) data Compressed = -- | Short messages are left intact to skip copying and FFI festivities. @@ -42,49 +31,15 @@ instance Encoding Compressed where '1' -> Compressed <$> smpP x -> fail $ "unknown Compressed tag: " <> show x --- | Compress as single chunk using stack-allocated context. compress1 :: ByteString -> Compressed compress1 bs | B.length bs <= maxLengthPassthrough = Passthrough bs | otherwise = Compressed . Large $ Z1.compress compressionLevel bs -type CompressCtx = (Ptr Z.CCtx, Ptr CChar, CSize) - -withCompressCtx :: CSize -> (CompressCtx -> IO a) -> IO a -withCompressCtx scratchSize action = - bracket Z.createCCtx Z.freeCCtx $ \cctx -> - allocaBytes (fromIntegral scratchSize) $ \scratchPtr -> - action (cctx, scratchPtr, scratchSize) - --- | Compress bytes, falling back to Passthrough in case of some internal error. -compress :: CompressCtx -> ByteString -> IO Compressed -compress ctx bs = fromRight (Passthrough bs) <$> compress_ ctx bs - -compress_ :: CompressCtx -> ByteString -> IO (Either String Compressed) -compress_ (cctx, scratchPtr, scratchSize) bs - | B.length bs <= maxLengthPassthrough = pure . Right $ Passthrough bs - | otherwise = - B.unsafeUseAsCStringLen bs $ \(sourcePtr, sourceSize) -> runExceptT $ do - -- should not fail, unless input buffer is too short - dstSize <- ExceptT $ Z.checkError $ Z.compressCCtx cctx scratchPtr scratchSize sourcePtr (fromIntegral sourceSize) compressionLevel - liftIO $ Compressed . Large <$> B.packCStringLen (scratchPtr, fromIntegral dstSize) - -type DecompressCtx = (Ptr Z.DCtx, Ptr CChar, CSize) - -withDecompressCtx :: Int -> (DecompressCtx -> IO a) -> IO a -withDecompressCtx maxUnpackedSize action = - bracket Z.createDCtx Z.freeDCtx $ \dctx -> - allocaBytes maxUnpackedSize $ \scratchPtr -> - action (dctx, scratchPtr, fromIntegral maxUnpackedSize) - -decompress :: DecompressCtx -> Compressed -> IO (Either String ByteString) -decompress (dctx, scratchPtr, scratchSize) = \case - Passthrough bs -> pure $ Right bs - Compressed (Large bs) -> - B.unsafeUseAsCStringLen bs $ \(sourcePtr, sourceSize) -> do - res <- Z.checkError $ Z.decompressDCtx dctx scratchPtr scratchSize sourcePtr (fromIntegral sourceSize) - forM res $ \dstSize -> B.packCStringLen (scratchPtr, fromIntegral dstSize) - -decompressBatch :: Int -> NonEmpty Compressed -> NonEmpty (Either String ByteString) -decompressBatch maxUnpackedSize items = unsafePerformIO $ withDecompressCtx maxUnpackedSize $ forM items . decompress -{-# NOINLINE decompressBatch #-} -- prevent double-evaluation under unsafePerformIO +decompress1 :: Compressed -> Either String ByteString +decompress1 = \case + Passthrough bs -> Right bs + Compressed (Large bs) -> case Z1.decompress bs of + Z1.Error e -> Left e + Z1.Skip -> Right mempty + Z1.Decompress bs' -> Right bs' From 935d2e25df80eed7761c48a01529b6c0da0d3baa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 25 Apr 2024 10:22:18 +0100 Subject: [PATCH 021/107] 5.7.0.3 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index a547609a1..b4c1f1d78 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.0.2 +version: 5.7.0.3 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index b48ad7d27..f353925bd 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.0.2 +version: 5.7.0.3 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 66eccbecb399004322b87d98759a15e9e79ccb95 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 1 May 2024 00:51:08 +0100 Subject: [PATCH 022/107] upgrade SMP/NTF servers to v7/v2 protocol versions (#996) * upgrade SMP/NTF servers to v7/v2 protocol versions * 5.6.0.0 --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- src/Simplex/Messaging/Notifications/Transport.hs | 2 +- src/Simplex/Messaging/Transport.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index 022403471..e2c287437 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -47,7 +47,7 @@ currentClientNTFVersion :: VersionNTF currentClientNTFVersion = VersionNTF 1 currentServerNTFVersion :: VersionNTF -currentServerNTFVersion = VersionNTF 1 +currentServerNTFVersion = VersionNTF 2 supportedClientNTFVRange :: VersionRangeNTF supportedClientNTFVRange = mkVersionRange initialNTFVersion currentClientNTFVersion diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 519154bb5..8dfd15813 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -153,7 +153,7 @@ currentClientSMPRelayVersion :: VersionSMP currentClientSMPRelayVersion = VersionSMP 6 currentServerSMPRelayVersion :: VersionSMP -currentServerSMPRelayVersion = VersionSMP 6 +currentServerSMPRelayVersion = VersionSMP 7 -- minimal supported protocol version is 4 -- TODO remove code that supports sending commands without batching From 323cdc412e3251b8684db6839f78d017d5a7164c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 1 May 2024 00:56:33 +0100 Subject: [PATCH 023/107] 5.7.0.4 --- CHANGELOG.md | 20 ++++++++++++++++++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e06ac1caa..ad8862b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 5.7.0 + +Version 5.7.0.4 + +_Please note_: the earliest SimpleX Chat clients supported by this version of the servers is 5.5.3 (released on February 11, 2024). + +SMP server: +- increase max SMP protocol version to 7 (support for deniable authenticators). + +NTF server: +- increase max NTF protocol version to 2 (support for deniable authenticators). + +XFTP server: +- version handshake using ALPN. + +SMP agent: +- increase timeouts for XFTP files. +- don't send commands after timeout. +- PQ encryption support. + # 5.6.2 Version 5.6.2.2. diff --git a/package.yaml b/package.yaml index b4c1f1d78..084fe3a8f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.0.3 +version: 5.7.0.4 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index f353925bd..1bdd67c0b 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.0.3 +version: 5.7.0.4 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 6d60de2429187f2db9bbf25a762846d3945823f5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 1 May 2024 08:48:33 +0100 Subject: [PATCH 024/107] proxy: agent implementation (#1106) * proxy: agent implementation * revert change * update rfc * test stuck subscription mock * store proxy sessions inside SMP client var * rename * create and use proxy session * tests * return proxy in SENT event * rename, more tests * rename * more tests * remove comment --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- rfcs/2023-09-12-second-relays.md | 50 +++++ src/Simplex/Messaging/Agent.hs | 22 +- src/Simplex/Messaging/Agent/Client.hs | 273 +++++++++++++++++------- src/Simplex/Messaging/Agent/Protocol.hs | 28 +-- src/Simplex/Messaging/Client.hs | 46 +++- tests/AgentTests.hs | 8 +- tests/AgentTests/FunctionalAPITests.hs | 86 ++++---- tests/AgentTests/NotificationTests.hs | 20 +- tests/SMPAgentClient.hs | 11 +- tests/SMPClient.hs | 8 +- tests/SMPProxyTests.hs | 157 +++++++++----- 11 files changed, 491 insertions(+), 218 deletions(-) diff --git a/rfcs/2023-09-12-second-relays.md b/rfcs/2023-09-12-second-relays.md index a47eb7bde..cad6c4a92 100644 --- a/rfcs/2023-09-12-second-relays.md +++ b/rfcs/2023-09-12-second-relays.md @@ -196,6 +196,56 @@ dhPublic = length x509encoded The above assumes that the client can only send one message to an SMP relay and then has to wait for response before sending the next message. Missing the response would cause re-delivery (further improvement is possible when proxy detects these redelieveries and not send them to relays but simply reply with the same response). +### Implementation considerations for the client + +While client/server protocol is rather straightforward to implement, and it is already working, there are some decisions to make about how the client makes decisions about. + +1. When to use proxy and when to connect directly to the destination relay. + +While from the perspective of threat model improvement it may be beneficial to always use the proxy, choosing the proxy that is different from other relays in the connection, initially we need to make it opt-in, with an option to only use it for unknown destination relays, to minimize any unexpected adverse effect on the delivery latency. + +Proxy mode will be passed from the client via NetworkConfig. + +2. Which proxying relays to use. + +Ability to request access to the session with the destination relay (and to create such session) is protected with the same basic auth approach as creating queues - the logic here is that opening private servers to all users as proxies would increase the scenarios for DoS attacks (which is the case with the public servers). + +The open question is whether the client should choose proxies from: +- all configured relays. +- there should be a subset of configured relays. +- there should be a separate list. + +E.g., there could be a second toggle in the relay configuration to allow using relay as proxy, in addition to the current toggle that allows creating queues. + +For simplicity, initially we will just use all enabled relays as potential proxies. + +3. How many proxying relays should be used during one session. + +This is not a simple question, and it creates a contradiction between two risks: +- collusion between proxies and destination relays simplifies correlating sending clients by session - from the point of view of this risk, clients should follow the same policy for creating connections with proxies, that is to create a new connection for each user profile, and if transport isolation is set to "per connection" - for each destination queue. +- traffic correlation by observable traffic sessions (particularly if an attacker can observe user's ISP traffic or multiple proxies) - from this point of view, it would be beneficial to use fewer proxies and fewer connections with proxies and see the risk of proxy colluding with the destination relay as lower than the risk of traffic observation that in the case of multiple sessions would allow to correlate traffic to rarely used destination relays (any private self-hosted relays) and the traffic of the user to a given proxy, to prove the fact of user communicating with the destination relay via the proxy. + +While we can transfer this choice on the users, it seems a complex decision to make, and overall the second risk (traffic correlation) seems more important to address than the first. + +In any case possible options are: +1. Extreme option 1: Create a new proxy session, with the new random proxy, for each potential transport session that would exist if the user were to be connected to destination relays directly. That is, never to mix access to multiple relays from multiple user profiles (and in case of per-connection isolation, to multiple queues) into a one client session with proxy. This is a rather radical option that nullifies any advantages of having fewer sessions with proxies than there would have been with the destination relays and removes any benefits of batching destination server session requests (PRXY comands). +2. Extreme option 2: Use only one proxy session at the time, mixing traffic from all user profiles and to all destination servers (and for all queues) into a session with one proxy. This minimizes the risks of traffic correlation in case of non-colluding proxy, but maximises the risk in case it colludes with the destination relays. +3. Balanced option: Use one proxy session per user profile, but mix traffic to multiple queues irrespective of connection isolation option and to all destination servers. Given that connection isolation is an experimental option, this makes the most sense, but it would have to be disclosed. +4. Less balanced option: take connection isolation option into account and create a new proxy connection for each destination queue. This feels worse than option 3. + +If option 3 is chosen, then the transport session key with the proxy would be different from the transport session key with the relay - proxy session will only use UserId as the key, and the relay session uses (UserId, Server, Maybe EntityId) as the key. + +If option 4 is chosen, the keys would also be different, as the proxy would then use (UserId, Maybe (Server, EntityId)) as the key. + +We could potentially key proxy sessions (and create proxy connections) per each destination relay, in the same way as we key relays themselves, but it seems to have the least sense, as we neither achieve isolation by queue in case proxy and destination relay collude, nor we sufficiently protect from traffic correlation by any observers. + +The implemented design is this: +- for each destination relay a random proxy is chosen and used to send all messages - all requests from a client coalesce to a single session. +- transport isolation mode is taken into account, that is if per-connection isolation is enabled, then a separate proxy connection will be created for each messaging queue. +- supported modes when proxy is used: always, for unknown relays, for unknown relays when IP address is not protected, never. + +This decision is made because the argument for protection against collusion between proxy and relay and more balanced traffic distribution is stronger than the argument for protection against traffic correlation, because even mixing all messages to one proxy connection does not provide protection against traffic correlation by time, so in any case it requires adding delays. + ### Threat model for SMP proxy and changes to threat model for SMP #### SMP proxy diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 2d9fedc65..600396700 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -149,7 +149,7 @@ import Simplex.FileTransfer.Protocol (FileParty (..)) import Simplex.FileTransfer.Util (removePath) import Simplex.Messaging.Agent.Client import Simplex.Messaging.Agent.Env.SQLite -import Simplex.Messaging.Agent.Lock (withLock', withLock) +import Simplex.Messaging.Agent.Lock (withLock, withLock') import Simplex.Messaging.Agent.NtfSubSupervisor import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.RetryInterval @@ -160,7 +160,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (ProtocolClient (..), ServerTransmission) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile, CryptoFileArgs) -import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..), pattern PQEncOn, pattern PQEncOff, pattern PQSupportOn, pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -198,7 +198,7 @@ getSMPAgentClient_ clientId cfg initServers store backgroundMode = liftIO $ newSMPAgentEnv cfg store >>= runReaderT runAgent where runAgent = do - c@AgentClient {acThread} <- atomically . newAgentClient clientId initServers =<< ask + c@AgentClient {acThread} <- atomically . newAgentClient clientId initServers =<< ask t <- runAgentThreads c `forkFinally` const (liftIO $ disconnectAgentClient c) atomically . writeTVar acThread . Just =<< mkWeakThreadId t pure c @@ -239,7 +239,7 @@ createUser c = withAgentEnv c .: createUser' c {-# INLINE createUser #-} -- | Delete user record optionally deleting all user's connections on SMP servers -deleteUser :: AgentClient -> UserId -> Bool -> AE () +deleteUser :: AgentClient -> UserId -> Bool -> AE () deleteUser c = withAgentEnv c .: deleteUser' c {-# INLINE deleteUser #-} @@ -815,7 +815,7 @@ joinConnSrv c userId connId enableNtfs cReqUri@CRContactUri {} cInfo pqSup subMo lift (compatibleContactUri cReqUri pqSup) >>= \case Just (qInfo, vrsn) -> do (connId', cReq) <- newConnSrv c userId connId enableNtfs SCMInvitation Nothing (CR.IKNoPQ pqSup) subMode srv - sendInvitation c userId qInfo vrsn cReq cInfo + void $ sendInvitation c userId qInfo vrsn cReq cInfo pure connId' Nothing -> throwError $ AGENT A_VERSION @@ -1209,7 +1209,7 @@ enqueueMessage c cData sq msgFlags aMessage = {-# INLINE enqueueMessage #-} -- this function is used only for sending messages in batch, it returns the list of successes to enqueue additional deliveries -enqueueMessageB :: forall t. (Traversable t) => AgentClient -> t (Either AgentErrorType (ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage)) -> AM' (t (Either AgentErrorType ((AgentMsgId, PQEncryption), Maybe (ConnData, [SndQueue], AgentMsgId)))) +enqueueMessageB :: forall t. Traversable t => AgentClient -> t (Either AgentErrorType (ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage)) -> AM' (t (Either AgentErrorType ((AgentMsgId, PQEncryption), Maybe (ConnData, [SndQueue], AgentMsgId)))) enqueueMessageB c reqs = do cfg <- asks config reqMids <- withStoreBatch c $ \db -> fmap (bindRight $ storeSentMsg db cfg) reqs @@ -1242,7 +1242,7 @@ enqueueSavedMessage :: AgentClient -> ConnData -> AgentMsgId -> SndQueue -> AM' enqueueSavedMessage c cData msgId sq = enqueueSavedMessageB c $ Identity (cData, [sq], msgId) {-# INLINE enqueueSavedMessage #-} -enqueueSavedMessageB :: (Foldable t) => AgentClient -> t (ConnData, [SndQueue], AgentMsgId) -> AM' () +enqueueSavedMessageB :: Foldable t => AgentClient -> t (ConnData, [SndQueue], AgentMsgId) -> AM' () enqueueSavedMessageB c reqs = do -- saving to the database is in the start to avoid race conditions when delivery is read from queue before it is saved void $ withStoreBatch' c $ \db -> concatMap (storeDeliveries db) reqs @@ -1333,7 +1333,7 @@ runSmpQueueMsgDelivery c@AgentClient {subQ} ConnData {connId} sq (Worker {doWork retrySndMsg riMode = do withStore' c $ \db -> updatePendingMsgRIState db connId msgId riState retrySndOp c $ loop riMode - Right () -> do + Right proxySrv_ -> do case msgType of AM_CONN_INFO -> setConfirmed AM_CONN_INFO_REPLY -> setConfirmed @@ -1355,7 +1355,7 @@ runSmpQueueMsgDelivery c@AgentClient {subQ} ConnData {connId} sq (Worker {doWork when (status == Active) $ notify $ CON pqEncryption -- this branch should never be reached as receive queue is created before the confirmation, _ -> logError "HELLO sent without receive queue" - AM_A_MSG_ -> notify $ SENT mId + AM_A_MSG_ -> notify $ SENT mId proxySrv_ AM_A_RCVD_ -> pure () AM_QCONT_ -> pure () AM_QADD_ -> pure () @@ -2212,7 +2212,7 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, where processEND = \case Just (Right clnt) - | sessId == sessionId (thParams clnt) -> do + | sessId == sessionId (thParams $ connectedClient clnt) -> do removeSubscription c connId notify' END pure "END" @@ -2574,7 +2574,7 @@ confirmQueueAsync c cData sq srv connInfo e2eEncryption_ subMode = do confirmQueue :: Compatible VersionSMPA -> AgentClient -> ConnData -> SndQueue -> SMPServerWithAuth -> ConnInfo -> Maybe (CR.SndE2ERatchetParams 'C.X448) -> SubscriptionMode -> AM () confirmQueue (Compatible agentVersion) c cData@ConnData {connId, pqSupport} sq srv connInfo e2eEncryption_ subMode = do msg <- mkConfirmation =<< mkAgentConfirmation c cData sq srv connInfo subMode - sendConfirmation c sq msg + void $ sendConfirmation c sq msg withStore' c $ \db -> setSndQueueStatus db sq Confirmed where mkConfirmation :: AgentMessage -> AM MsgBody diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 27223b12f..c29e35499 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -132,6 +132,8 @@ module Simplex.Messaging.Agent.Client SMPTransportSession, NtfTransportSession, XFTPTransportSession, + ProxiedRelay (..), + SMPConnectedClient (..), ) where @@ -230,7 +232,7 @@ import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (SMPVersion) -import Simplex.Messaging.Transport.Client (TransportHost) +import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util import Simplex.Messaging.Version import System.Mem.Weak (Weak) @@ -263,6 +265,10 @@ data AgentClient = AgentClient msgQ :: TBQueue (ServerTransmission SMPVersion BrokerMsg), smpServers :: TMap UserId (NonEmpty SMPServerWithAuth), smpClients :: TMap SMPTransportSession SMPClientVar, + -- smpProxiedRelays: + -- SMPTransportSession defines connection from proxy to relay, + -- SMPServerWithAuth defines client connected to SMP proxy (with the same userId and entityId in TransportSession) + smpProxiedRelays :: TMap SMPTransportSession SMPServerWithAuth, ntfServers :: TVar [NtfServer], ntfClients :: TMap NtfTransportSession NtfClientVar, xftpServers :: TMap UserId (NonEmpty XFTPServerWithAuth), @@ -297,6 +303,13 @@ data AgentClient = AgentClient agentEnv :: Env } +data SMPConnectedClient = SMPConnectedClient + { connectedClient :: SMPClient, + proxiedRelays :: TMap SMPServer ProxiedRelayVar + } + +type ProxiedRelayVar = SessionVar (Either AgentErrorType ProxiedRelay) + getAgentWorker :: (Ord k, Show k) => String -> Bool -> AgentClient -> k -> TMap k Worker -> (Worker -> AM ()) -> AM' Worker getAgentWorker = getAgentWorker' id pure {-# INLINE getAgentWorker #-} @@ -428,6 +441,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = msgQ <- newTBQueue qSize smpServers <- newTVar smp smpClients <- TM.empty + smpProxiedRelays <- TM.empty ntfServers <- newTVar ntf ntfClients <- TM.empty xftpServers <- newTVar xftp @@ -463,6 +477,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = msgQ, smpServers, smpClients, + smpProxiedRelays, ntfServers, ntfClients, xftpServers, @@ -511,15 +526,19 @@ agentDRG AgentClient {agentEnv = Env {random}} = random class (Encoding err, Show err) => ProtocolServerClient v err msg | msg -> v, msg -> err where type Client msg = c | c -> msg getProtocolServerClient :: AgentClient -> TransportSession msg -> AM (Client msg) + type ProtoClient msg = c | c -> msg + protocolClient :: Client msg -> ProtoClient msg clientProtocolError :: err -> AgentErrorType - closeProtocolServerClient :: Client msg -> IO () - clientServer :: Client msg -> String - clientTransportHost :: Client msg -> TransportHost - clientSessionTs :: Client msg -> UTCTime + closeProtocolServerClient :: ProtoClient msg -> IO () + clientServer :: ProtoClient msg -> String + clientTransportHost :: ProtoClient msg -> TransportHost + clientSessionTs :: ProtoClient msg -> UTCTime instance ProtocolServerClient SMPVersion ErrorType BrokerMsg where - type Client BrokerMsg = ProtocolClient SMPVersion ErrorType BrokerMsg + type Client BrokerMsg = SMPConnectedClient getProtocolServerClient = getSMPServerClient + type ProtoClient BrokerMsg = ProtocolClient SMPVersion ErrorType BrokerMsg + protocolClient = connectedClient clientProtocolError = SMP closeProtocolServerClient = closeProtocolClient clientServer = protocolClientServer @@ -529,6 +548,8 @@ instance ProtocolServerClient SMPVersion ErrorType BrokerMsg where instance ProtocolServerClient NTFVersion ErrorType NtfResponse where type Client NtfResponse = ProtocolClient NTFVersion ErrorType NtfResponse getProtocolServerClient = getNtfServerClient + type ProtoClient NtfResponse = ProtocolClient NTFVersion ErrorType NtfResponse + protocolClient = id clientProtocolError = NTF closeProtocolServerClient = closeProtocolClient clientServer = protocolClientServer @@ -538,61 +559,120 @@ instance ProtocolServerClient NTFVersion ErrorType NtfResponse where instance ProtocolServerClient XFTPVersion XFTPErrorType FileResponse where type Client FileResponse = XFTPClient getProtocolServerClient = getXFTPServerClient + type ProtoClient FileResponse = XFTPClient + protocolClient = id clientProtocolError = XFTP closeProtocolServerClient = X.closeXFTPClient clientServer = X.xftpClientServer clientTransportHost = X.xftpTransportHost clientSessionTs = X.xftpSessionTs -getSMPServerClient :: AgentClient -> SMPTransportSession -> AM SMPClient -getSMPServerClient c@AgentClient {active, smpClients, msgQ, workerSeq} tSess@(userId, srv, _) = do +getSMPServerClient :: AgentClient -> SMPTransportSession -> AM SMPConnectedClient +getSMPServerClient c@AgentClient {active, smpClients, workerSeq} tSess = do unlessM (readTVarIO active) . throwError $ INACTIVE atomically (getSessVar workerSeq tSess smpClients) >>= either newClient (waitForProtocolClient c tSess) where - -- we resubscribe only on newClient error, but not on waitForProtocolClient error, - -- as the large number of delivery workers waiting for the client TMVar - -- make it expensive to check for pending subscriptions. - newClient v = - newProtocolClient c tSess smpClients connectClient v - `catchAgentError` \e -> lift (resubscribeSMPSession c tSess) >> throwError e - connectClient :: SMPClientVar -> AM SMPClient - connectClient v = do + newClient v = do + prs <- atomically TM.empty + smpConnectClient c tSess prs v + +getSMPProxyClient :: AgentClient -> SMPTransportSession -> AM (SMPConnectedClient, ProxiedRelay) +getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq} destSess@(userId, destSrv, qId) = do + unlessM (readTVarIO active) . throwError $ INACTIVE + proxySrv <- getNextServer c userId [destSrv] + atomically (getClientVar proxySrv) >>= \(tSess, auth, v) -> + either (newProxyClient tSess auth) (waitForProxyClient tSess auth) v + where + getClientVar :: SMPServerWithAuth -> STM (SMPTransportSession, Maybe SMP.BasicAuth, Either SMPClientVar SMPClientVar) + getClientVar proxySrv = do + ProtoServerWithAuth srv auth <- TM.lookup destSess smpProxiedRelays >>= maybe (TM.insert destSess proxySrv smpProxiedRelays $> proxySrv) pure + let tSess = (userId, srv, qId) + (tSess,auth,) <$> getSessVar workerSeq tSess smpClients + newProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, ProxiedRelay) + newProxyClient tSess auth v = do + (prs, rv) <- atomically $ do + prs <- TM.empty + -- we do not need to check if it is a new proxied relay session, + -- as the client is just created and there are no sessions yet + (prs,) . either id id <$> getSessVar workerSeq destSrv prs + clnt <- smpConnectClient c tSess prs v + (clnt,) <$> newProxiedRelay clnt auth rv + waitForProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, ProxiedRelay) + waitForProxyClient tSess auth v = do + clnt@(SMPConnectedClient _ prs) <- waitForProtocolClient c tSess v + sess <- + atomically (getSessVar workerSeq destSrv prs) + >>= either (newProxiedRelay clnt auth) (waitForProxiedRelay tSess) + pure (clnt, sess) + newProxiedRelay :: SMPConnectedClient -> Maybe SMP.BasicAuth -> ProxiedRelayVar -> AM ProxiedRelay + newProxiedRelay clnt@(SMPConnectedClient smp prs) proxyAuth rv = + tryAgentError (liftClient SMP (clientServer smp) $ connectSMPProxiedRelay smp destSrv proxyAuth) >>= \case + Right sess -> do + atomically $ putTMVar (sessionVar rv) (Right sess) + liftIO $ incClientStat c userId clnt "PROXY" "OK" + pure sess + Left e -> do + liftIO $ incClientStat c userId clnt "PROXY" $ strEncode e + atomically $ do + removeSessVar rv destSrv prs + TM.delete destSess smpProxiedRelays + putTMVar (sessionVar rv) (Left e) + throwError e -- signal error to caller + waitForProxiedRelay :: SMPTransportSession -> ProxiedRelayVar -> AM ProxiedRelay + waitForProxiedRelay (_, srv, _) rv = do + NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c + sess_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar rv) + liftEither $ case sess_ of + Just (Right sess) -> Right sess + Just (Left e) -> Left e + Nothing -> Left $ BROKER (B.unpack $ strEncode srv) TIMEOUT + +smpConnectClient :: AgentClient -> SMPTransportSession -> TMap SMPServer ProxiedRelayVar -> SMPClientVar -> AM SMPConnectedClient +smpConnectClient c@AgentClient {smpClients, msgQ} tSess@(_, srv, _) prs v = + newProtocolClient c tSess smpClients connectClient v + `catchAgentError` \e -> lift (resubscribeSMPSession c tSess) >> throwError e + where + connectClient :: SMPClientVar -> AM SMPConnectedClient + connectClient v' = do cfg <- lift $ getClientConfig c smpCfg g <- asks random env <- ask - liftError' (protocolClientError SMP $ B.unpack $ strEncode srv) $ - getProtocolClient g tSess cfg (Just msgQ) $ - clientDisconnected env v + liftError (protocolClientError SMP $ B.unpack $ strEncode srv) $ do + smp <- ExceptT $ getProtocolClient g tSess cfg (Just msgQ) $ smpClientDisconnected c tSess env v' prs + pure SMPConnectedClient {connectedClient = smp, proxiedRelays = prs} - clientDisconnected :: Env -> SMPClientVar -> SMPClient -> IO () - clientDisconnected env v client = do - removeClientAndSubs >>= serverDown - logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv +smpClientDisconnected :: AgentClient -> SMPTransportSession -> Env -> SMPClientVar -> TMap SMPServer ProxiedRelayVar -> SMPClient -> IO () +smpClientDisconnected c@AgentClient {active, smpClients, smpProxiedRelays} tSess@(userId, srv, qId) env v prs client = do + removeClientAndSubs >>= serverDown + logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv + where + -- we make active subscriptions pending only if the client for tSess was current (in the map) and active, + -- because we can have a race condition when a new current client could have already + -- made subscriptions active, and the old client would be processing diconnection later. + removeClientAndSubs :: IO ([RcvQueue], [ConnId]) + removeClientAndSubs = atomically $ ifM currentActiveClient removeSubs $ pure ([], []) where - -- we make active subscriptions pending only if the client for tSess was current (in the map) and active, - -- because we can have a race condition when a new current client could have already - -- made subscriptions active, and the old client would be processing diconnection later. - removeClientAndSubs :: IO ([RcvQueue], [ConnId]) - removeClientAndSubs = atomically $ ifM currentActiveClient removeSubs $ pure ([], []) - where - currentActiveClient = (&&) <$> removeSessVar' v tSess smpClients <*> readTVar active - removeSubs = do - (qs, cs) <- RQ.getDelSessQueues tSess $ activeSubs c - RQ.batchAddQueues (pendingSubs c) qs - pure (qs, cs) + currentActiveClient = (&&) <$> removeSessVar' v tSess smpClients <*> readTVar active + removeSubs = do + (qs, cs) <- RQ.getDelSessQueues tSess $ activeSubs c + RQ.batchAddQueues (pendingSubs c) qs + -- this removes proxied relays that this client created sessions to + destSrvs <- M.keys <$> readTVar prs + forM_ destSrvs $ \destSrv -> TM.delete (userId, destSrv, qId) smpProxiedRelays + pure (qs, cs) - serverDown :: ([RcvQueue], [ConnId]) -> IO () - serverDown (qs, conns) = whenM (readTVarIO active) $ do - incClientStat c userId client "DISCONNECT" "" - notifySub "" $ hostEvent DISCONNECT client - unless (null conns) $ notifySub "" $ DOWN srv conns - unless (null qs) $ do - atomically $ mapM_ (releaseGetLock c) qs - runReaderT (resubscribeSMPSession c tSess) env + serverDown :: ([RcvQueue], [ConnId]) -> IO () + serverDown (qs, conns) = whenM (readTVarIO active) $ do + incClientStat' c userId client "DISCONNECT" "" + notifySub "" $ hostEvent' DISCONNECT client + unless (null conns) $ notifySub "" $ DOWN srv conns + unless (null qs) $ do + atomically $ mapM_ (releaseGetLock c) qs + runReaderT (resubscribeSMPSession c tSess) env - notifySub :: forall e. AEntityI e => ConnId -> ACommand 'Agent e -> IO () - notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) + notifySub :: forall e. AEntityI e => ConnId -> ACommand 'Agent e -> IO () + notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) resubscribeSMPSession :: AgentClient -> SMPTransportSession -> AM' () resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = @@ -735,7 +815,11 @@ newProtocolClient c tSess@(userId, srv, entityId_) clients connectClient v = throwError e -- signal error to caller hostEvent :: forall v err msg. (ProtocolTypeI (ProtoType msg), ProtocolServerClient v err msg) => (AProtocolType -> TransportHost -> ACommand 'Agent 'AENone) -> Client msg -> ACommand 'Agent 'AENone -hostEvent event = event (AProtocolType $ protocolTypeI @(ProtoType msg)) . clientTransportHost +hostEvent event = hostEvent' event . protocolClient +{-# INLINE hostEvent #-} + +hostEvent' :: forall v err msg. (ProtocolTypeI (ProtoType msg), ProtocolServerClient v err msg) => (AProtocolType -> TransportHost -> ACommand 'Agent 'AENone) -> ProtoClient msg -> ACommand 'Agent 'AENone +hostEvent' event = event (AProtocolType $ protocolTypeI @(ProtoType msg)) . clientTransportHost getClientConfig :: AgentClient -> (AgentConfig -> ProtocolClientConfig v) -> AM' (ProtocolClientConfig v) getClientConfig c cfgSel = do @@ -842,7 +926,7 @@ closeClient_ c v = do NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c E.handle (\BlockedIndefinitelyOnSTM -> pure ()) $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) >>= \case - Just (Right client) -> closeProtocolServerClient client `catchAll_` pure () + Just (Right client) -> closeProtocolServerClient (protocolClient client) `catchAll_` pure () _ -> pure () closeXFTPServerClient :: AgentClient -> UserId -> XFTPServer -> FileDigest -> IO () @@ -895,6 +979,22 @@ withClient_ c tSess@(userId, srv, _) statCmd action = do stat cl $ strEncode e throwError e +withProxySession :: AgentClient -> SMPTransportSession -> SMP.SenderId -> ByteString -> ((SMPConnectedClient, ProxiedRelay) -> AM a) -> AM a +withProxySession c destSess@(userId, destSrv, _) entId cmdStr action = do + cp@(cl, _) <- getSMPProxyClient c destSess + logServer ("--> " <> proxySrv cl <> " >") c destSrv entId cmdStr + r <- (action cp <* stat cl "OK") `catchAgentError` logServerError cl + logServer ("<-- " <> proxySrv cl <> " <") c destSrv entId "OK" + pure r + where + stat cl = liftIO . incClientStat c userId cl cmdStr + proxySrv = showServer . protocolClientServer' . protocolClient + logServerError :: SMPConnectedClient -> AgentErrorType -> AM a + logServerError cl e = do + logServer ("<-- " <> proxySrv cl <> " <") c destSrv "" $ strEncode e + stat cl $ strEncode e + throwError e + withLogClient_ :: ProtocolServerClient v err msg => AgentClient -> TransportSession msg -> EntityId -> ByteString -> (Client msg -> AM a) -> AM a withLogClient_ c tSess@(_, srv, _) entId cmdStr action = do logServer "-->" c srv entId cmdStr @@ -903,22 +1003,46 @@ withLogClient_ c tSess@(_, srv, _) entId cmdStr action = do return res withClient :: forall v err msg a. ProtocolServerClient v err msg => AgentClient -> TransportSession msg -> ByteString -> (Client msg -> ExceptT (ProtocolClientError err) IO a) -> AM a -withClient c tSess statKey action = withClient_ c tSess statKey $ \client -> liftClient (clientProtocolError @v @err @msg) (clientServer client) $ action client +withClient c tSess statKey action = withClient_ c tSess statKey $ \client -> liftClient (clientProtocolError @v @err @msg) (clientServer $ protocolClient client) $ action client {-# INLINE withClient #-} withLogClient :: forall v err msg a. ProtocolServerClient v err msg => AgentClient -> TransportSession msg -> EntityId -> ByteString -> (Client msg -> ExceptT (ProtocolClientError err) IO a) -> AM a -withLogClient c tSess entId cmdStr action = withLogClient_ c tSess entId cmdStr $ \client -> liftClient (clientProtocolError @v @err @msg) (clientServer client) $ action client +withLogClient c tSess entId cmdStr action = withLogClient_ c tSess entId cmdStr $ \client -> liftClient (clientProtocolError @v @err @msg) (clientServer $ protocolClient client) $ action client {-# INLINE withLogClient #-} withSMPClient :: SMPQueueRec q => AgentClient -> q -> ByteString -> (SMPClient -> ExceptT SMPClientError IO a) -> AM a withSMPClient c q cmdStr action = do tSess <- liftIO $ mkSMPTransportSession c q - withLogClient c tSess (queueId q) cmdStr action + withLogClient c tSess (queueId q) cmdStr $ action . connectedClient -withSMPClient_ :: SMPQueueRec q => AgentClient -> q -> ByteString -> (SMPClient -> AM a) -> AM a -withSMPClient_ c q cmdStr action = do - tSess <- liftIO $ mkSMPTransportSession c q - withLogClient_ c tSess (queueId q) cmdStr action +sendOrProxySMPMessage :: AgentClient -> UserId -> SMPServer -> ByteString -> Maybe SMP.SndPrivateAuthKey -> SMP.SenderId -> MsgFlags -> SMP.MsgBody -> AM (Maybe SMPServer) +sendOrProxySMPMessage c userId destSrv cmdStr spKey_ senderId msgFlags msg = do + sess <- liftIO $ mkTransportSession c userId destSrv senderId + ifM (atomically shouldUseProxy) (sendViaProxy sess) (sendDirectly sess $> Nothing) + where + shouldUseProxy = do + cfg <- getNetworkConfig c + case smpProxyMode cfg of + SPMAlways -> pure True + SPMUnknown -> unknownServer + SPMUnprotected + | ipAddressProtected cfg destSrv -> pure False + | otherwise -> unknownServer + SPMNever -> pure False + unknownServer = maybe True (all ((destSrv /=) . protoServer)) <$> TM.lookup userId (userServers c) + sendViaProxy destSess = + withProxySession c destSess senderId ("PFWD " <> cmdStr) $ \(SMPConnectedClient smp _, proxySess) -> do + liftClient SMP (clientServer smp) $ proxySMPMessage smp proxySess spKey_ senderId msgFlags msg + pure . Just $ protocolClientServer' smp + sendDirectly tSess = + withLogClient_ c tSess senderId ("SEND " <> cmdStr) $ \(SMPConnectedClient smp _) -> + liftClient SMP (clientServer smp) $ sendSMPMessage smp spKey_ senderId msgFlags msg + +ipAddressProtected :: NetworkConfig -> ProtocolServer p -> Bool +ipAddressProtected NetworkConfig {socksProxy, hostMode} (ProtocolServer _ hosts _ _) = do + isJust socksProxy || (hostMode == HMOnion && any isOnionHost hosts) + where + isOnionHost = \case THOnionHost _ -> True; _ -> False withNtfClient :: AgentClient -> NtfServer -> EntityId -> ByteString -> (NtfClient -> ExceptT NtfClientError IO a) -> AM a withNtfClient c srv = withLogClient c (0, srv, Nothing) @@ -989,7 +1113,6 @@ runSMPServerTest c userId (ProtoServerWithAuth srv auth) = do liftError (testErr TSSecureQueue) $ secureSMPQueue smp rpKey rcvId sKey liftError (testErr TSDeleteQueue) $ deleteSMPQueue smp rpKey rcvId ok <- tcpTimeout (networkConfig cfg) `timeout` closeProtocolClient smp - incClientStat c userId smp "SMP_TEST" "OK" pure $ either Just (const Nothing) r <|> maybe (Just (ProtocolTestFailure TSDisconnect $ BROKER addr TIMEOUT)) (const Nothing) ok Left e -> pure (Just $ testErr TSConnect e) where @@ -1104,7 +1227,7 @@ newRcvQueue c userId connId (ProtoServerWithAuth srv auth) vRange subMode = do logServer "-->" c srv "" "NEW" tSess <- liftIO $ mkTransportSession c userId srv connId QIK {rcvId, sndId, rcvPublicDhKey} <- - withClient c tSess "NEW" $ \smp -> createSMPQueue smp rKeys dhKey auth subMode + withClient c tSess "NEW" $ \smp -> createSMPQueue (connectedClient smp) rKeys dhKey auth subMode liftIO . logServer "<--" c srv "" $ B.unwords ["IDS", logSecret rcvId, logSecret sndId] let rq = RcvQueue @@ -1193,7 +1316,7 @@ sendTSessionBatches statCmd statBatchSize toRQ action c qs = sendClientBatch (tSess@(userId, srv, _), qs') = tryAgentError' (getSMPServerClient c tSess) >>= \case Left e -> pure $ L.map ((,Left e) . toRQ) qs' - Right smp -> liftIO $ do + Right (SMPConnectedClient smp _) -> liftIO $ do logServer "-->" c srv (bshow (length qs') <> " queues") statCmd rs <- L.map agentError <$> action smp qs' statBatch @@ -1243,20 +1366,17 @@ logSecret :: ByteString -> ByteString logSecret bs = encode $ B.take 3 bs {-# INLINE logSecret #-} -sendConfirmation :: AgentClient -> SndQueue -> ByteString -> AM () -sendConfirmation c sq@SndQueue {sndId, sndPublicKey = Just sndPublicKey, e2ePubKey = e2ePubKey@Just {}} agentConfirmation = - withSMPClient_ c sq "SEND " $ \smp -> do - let clientMsg = SMP.ClientMessage (SMP.PHConfirmation sndPublicKey) agentConfirmation - msg <- agentCbEncrypt sq e2ePubKey $ smpEncode clientMsg - liftClient SMP (clientServer smp) $ sendSMPMessage smp Nothing sndId (SMP.MsgFlags {notification = True}) msg +sendConfirmation :: AgentClient -> SndQueue -> ByteString -> AM (Maybe SMPServer) +sendConfirmation c sq@SndQueue {userId, server, sndId, sndPublicKey = Just sndPublicKey, e2ePubKey = e2ePubKey@Just {}} agentConfirmation = do + let clientMsg = SMP.ClientMessage (SMP.PHConfirmation sndPublicKey) agentConfirmation + msg <- agentCbEncrypt sq e2ePubKey $ smpEncode clientMsg + sendOrProxySMPMessage c userId server "" Nothing sndId (MsgFlags {notification = True}) msg sendConfirmation _ _ _ = throwError $ INTERNAL "sendConfirmation called without snd_queue public key(s) in the database" -sendInvitation :: AgentClient -> UserId -> Compatible SMPQueueInfo -> Compatible VersionSMPA -> ConnectionRequestUri 'CMInvitation -> ConnInfo -> AM () +sendInvitation :: AgentClient -> UserId -> Compatible SMPQueueInfo -> Compatible VersionSMPA -> ConnectionRequestUri 'CMInvitation -> ConnInfo -> AM (Maybe SMPServer) sendInvitation c userId (Compatible (SMPQueueInfo v SMPQueueAddress {smpServer, senderId, dhPublicKey})) (Compatible agentVersion) connReq connInfo = do - tSess <- liftIO $ mkTransportSession c userId smpServer senderId - withLogClient_ c tSess senderId "SEND " $ \smp -> do - msg <- mkInvitation - liftClient SMP (clientServer smp) $ sendSMPMessage smp Nothing senderId MsgFlags {notification = True} msg + msg <- mkInvitation + sendOrProxySMPMessage c userId smpServer "" Nothing senderId (MsgFlags {notification = True}) msg where mkInvitation :: AM ByteString -- this is only encrypted with per-queue E2E, not with double ratchet @@ -1340,12 +1460,11 @@ deleteQueue c rq@RcvQueue {rcvId, rcvPrivateKey} = do deleteQueues :: AgentClient -> [RcvQueue] -> AM' [(RcvQueue, Either AgentErrorType ())] deleteQueues = sendTSessionBatches "DEL" 90 id $ sendBatch deleteSMPQueues -sendAgentMessage :: AgentClient -> SndQueue -> MsgFlags -> ByteString -> AM () -sendAgentMessage c sq@SndQueue {sndId, sndPrivateKey} msgFlags agentMsg = - withSMPClient_ c sq "SEND " $ \smp -> do - let clientMsg = SMP.ClientMessage SMP.PHEmpty agentMsg - msg <- agentCbEncrypt sq Nothing $ smpEncode clientMsg - liftClient SMP (clientServer smp) $ sendSMPMessage smp (Just sndPrivateKey) sndId msgFlags msg +sendAgentMessage :: AgentClient -> SndQueue -> MsgFlags -> ByteString -> AM (Maybe SMPServer) +sendAgentMessage c sq@SndQueue {userId, server, sndId, sndPrivateKey} msgFlags agentMsg = do + let clientMsg = SMP.ClientMessage SMP.PHEmpty agentMsg + msg <- agentCbEncrypt sq Nothing $ smpEncode clientMsg + sendOrProxySMPMessage c userId server "" (Just sndPrivateKey) sndId msgFlags msg agentNtfRegisterToken :: AgentClient -> NtfToken -> NtfPublicAuthKey -> C.PublicKeyX25519 -> AM (NtfTokenId, C.PublicKeyX25519) agentNtfRegisterToken c NtfToken {deviceToken, ntfServer, ntfPrivKey} ntfPubKey pubDhKey = @@ -1606,9 +1725,13 @@ incStat AgentClient {agentStats} n k = do _ -> newTVar n >>= \v -> TM.insert k v agentStats incClientStat :: ProtocolServerClient v err msg => AgentClient -> UserId -> Client msg -> ByteString -> ByteString -> IO () -incClientStat c userId pc = incClientStatN c userId pc 1 +incClientStat c userId = incClientStat' c userId . protocolClient {-# INLINE incClientStat #-} +incClientStat' :: ProtocolServerClient v err msg => AgentClient -> UserId -> ProtoClient msg -> ByteString -> ByteString -> IO () +incClientStat' c userId pc = incClientStatN c userId pc 1 +{-# INLINE incClientStat' #-} + incServerStat :: AgentClient -> UserId -> ProtocolServer p -> ByteString -> ByteString -> IO () incServerStat c userId ProtocolServer {host} cmd res = do threadDelay 100000 @@ -1616,7 +1739,7 @@ incServerStat c userId ProtocolServer {host} cmd res = do where statsKey = AgentStatsKey {userId, host = strEncode $ L.head host, clientTs = "", cmd, res} -incClientStatN :: ProtocolServerClient v err msg => AgentClient -> UserId -> Client msg -> Int -> ByteString -> ByteString -> IO () +incClientStatN :: ProtocolServerClient v err msg => AgentClient -> UserId -> ProtoClient msg -> Int -> ByteString -> ByteString -> IO () incClientStatN c userId pc n cmd res = do atomically $ incStat c n statsKey where diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 4ee8d373f..602ffafe4 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -193,13 +193,13 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet ( InitialKeys (..), PQEncryption (..), - pattern PQEncOff, PQSupport, - pattern PQSupportOn, - pattern PQSupportOff, RcvE2ERatchetParams, RcvE2ERatchetParamsUri, - SndE2ERatchetParams + SndE2ERatchetParams, + pattern PQEncOff, + pattern PQSupportOff, + pattern PQSupportOn, ) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -213,14 +213,14 @@ import Simplex.Messaging.Protocol MsgId, NMsgMeta, ProtocolServer (..), + SMPClientVersion, SMPMsgMeta, SMPServer, SMPServerWithAuth, SndPublicAuthKey, SubscriptionMode, - SMPClientVersion, - VersionSMPC, VersionRangeSMPC, + VersionSMPC, initialSMPClientVersion, legacyEncodeServer, legacyServerP, @@ -398,7 +398,7 @@ data ACommand (p :: AParty) (e :: AEntity) where RSYNC :: RatchetSyncState -> Maybe AgentCryptoError -> ConnectionStats -> ACommand Agent AEConn SEND :: PQEncryption -> MsgFlags -> MsgBody -> ACommand Client AEConn MID :: AgentMsgId -> PQEncryption -> ACommand Agent AEConn - SENT :: AgentMsgId -> ACommand Agent AEConn + SENT :: AgentMsgId -> Maybe SMPServer -> ACommand Agent AEConn MERR :: AgentMsgId -> AgentErrorType -> ACommand Agent AEConn MERRS :: NonEmpty AgentMsgId -> AgentErrorType -> ACommand Agent AEConn MSG :: MsgMeta -> MsgFlags -> MsgBody -> ACommand Agent AEConn @@ -517,7 +517,7 @@ aCommandTag = \case RSYNC {} -> RSYNC_ SEND {} -> SEND_ MID {} -> MID_ - SENT _ -> SENT_ + SENT {} -> SENT_ MERR {} -> MERR_ MERRS {} -> MERRS_ MSG {} -> MSG_ @@ -913,7 +913,7 @@ instance Encoding AgentMsgEnvelope where -- AgentRatchetInfo is not encrypted with double ratchet, but with per-queue E2E encryption data AgentMessage = -- used by the initiating party when confirming reply queue - AgentConnInfo ConnInfo + AgentConnInfo ConnInfo | -- AgentConnInfoReply is used by accepting party in duplexHandshake mode (v2), allowing to include reply queue(s) in the initial confirmation. -- It made removed REPLY message unnecessary. AgentConnInfoReply (NonEmpty SMPQueueInfo) ConnInfo @@ -1387,9 +1387,9 @@ deriving instance Show (ConnectionRequestUri m) data AConnectionRequestUri = forall m. ConnectionModeI m => ACR (SConnectionMode m) (ConnectionRequestUri m) instance Eq AConnectionRequestUri where - ACR m cr == ACR m' cr' = case testEquality m m' of - Just Refl -> cr == cr' - _ -> False + ACR m cr == ACR m' cr' = case testEquality m m' of + Just Refl -> cr == cr' + _ -> False deriving instance Show AConnectionRequestUri @@ -1793,7 +1793,7 @@ commandP binaryP = SWITCH_ -> s (SWITCH <$> strP_ <*> strP_ <*> strP) RSYNC_ -> s (RSYNC <$> strP_ <*> strP <*> strP) MID_ -> s (MID <$> A.decimal <*> _strP) - SENT_ -> s (SENT <$> A.decimal) + SENT_ -> s (SENT <$> A.decimal <*> _strP) MERR_ -> s (MERR <$> A.decimal <* A.space <*> strP) MERRS_ -> s (MERRS <$> strP_ <*> strP) MSG_ -> s (MSG <$> strP <* A.space <*> smpP <* A.space <*> binaryP) @@ -1856,7 +1856,7 @@ serializeCommand = \case RSYNC rrState cryptoErr cstats -> s (RSYNC_, rrState, cryptoErr, cstats) SEND pqEnc msgFlags msgBody -> B.unwords [s SEND_, s pqEnc, smpEncode msgFlags, serializeBinary msgBody] MID mId pqEnc -> s (MID_, mId, pqEnc) - SENT mId -> s (SENT_, mId) + SENT mId proxySrv_ -> s (SENT_, mId, proxySrv_) MERR mId e -> s (MERR_, mId, e) MERRS mIds e -> s (MERRS_, mIds, e) MSG msgMeta msgFlags msgBody -> B.unwords [s MSG_, s msgMeta, smpEncode msgFlags, serializeBinary msgBody] diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 8d4db29d9..ebcfc79b2 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -30,9 +30,11 @@ module Simplex.Messaging.Client TransportSession, ProtocolClient (thParams, sessionTs), SMPClient, + ProxiedRelay (..), getProtocolClient, closeProtocolClient, protocolClientServer, + protocolClientServer', transportHost', transportSession', @@ -54,7 +56,7 @@ module Simplex.Messaging.Client suspendSMPQueue, deleteSMPQueue, deleteSMPQueues, - createSMPProxySession, + connectSMPProxiedRelay, proxySMPMessage, forwardSMPMessage, sendProtocolCommand, @@ -65,6 +67,8 @@ module Simplex.Messaging.Client ProtocolClientConfig (..), NetworkConfig (..), TransportSessionMode (..), + HostMode (..), + SMPProxyMode (..), defaultClientConfig, defaultSMPClientConfig, defaultNetworkConfig, @@ -207,6 +211,8 @@ data NetworkConfig = NetworkConfig requiredHostMode :: Bool, -- | transport sessions are created per user or per entity sessionMode :: TransportSessionMode, + -- | SMP proxy mode + smpProxyMode :: SMPProxyMode, -- | timeout for the initial client TCP/TLS connection (microseconds) tcpConnectTimeout :: Int, -- | timeout of protocol commands (microseconds) @@ -226,6 +232,14 @@ data NetworkConfig = NetworkConfig data TransportSessionMode = TSMUser | TSMEntity deriving (Eq, Show) +-- SMP proxy mode for sending messages +data SMPProxyMode + = SPMAlways + | SPMUnknown -- use with unknown relays + | SPMUnprotected -- use with unknown relays when IP address is not protected (i.e., when neither SOCKS proxy nor .onion address is used) + | SPMNever + deriving (Eq, Show) + defaultNetworkConfig :: NetworkConfig defaultNetworkConfig = NetworkConfig @@ -233,6 +247,7 @@ defaultNetworkConfig = hostMode = HMOnionViaSocks, requiredHostMode = False, sessionMode = TSMUser, + smpProxyMode = SPMNever, tcpConnectTimeout = 20_000_000, tcpTimeout = 15_000_000, tcpTimeoutPerKb = 5_000, @@ -302,10 +317,14 @@ chooseTransportHost NetworkConfig {socksProxy, hostMode, requiredHostMode} hosts publicHost = find (not . isOnionHost) hosts protocolClientServer :: ProtocolTypeI (ProtoType msg) => ProtocolClient v err msg -> String -protocolClientServer = B.unpack . strEncode . snd3 . transportSession . client_ +protocolClientServer = B.unpack . strEncode . protocolClientServer' +{-# INLINE protocolClientServer #-} + +protocolClientServer' :: ProtocolClient v err msg -> ProtoServer msg +protocolClientServer' = snd3 . transportSession . client_ where snd3 (_, s, _) = s -{-# INLINE protocolClientServer #-} +{-# INLINE protocolClientServer' #-} transportHost' :: ProtocolClient v err msg -> TransportHost transportHost' = transportHost . client_ @@ -650,14 +669,13 @@ deleteSMPQueues = okSMPCommands DEL -- send PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- receives PKEY :: SessionId -> X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg -createSMPProxySession :: SMPClient -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO (SessionId, VersionSMP, C.PublicKeyX25519) -createSMPProxySession c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth = +connectSMPProxiedRelay :: SMPClient -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO ProxiedRelay +connectSMPProxiedRelay c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth = sendSMPCommand c Nothing "" (PRXY relayServ proxyAuth) >>= \case - -- XXX: rfc says sessionId should be in the entityId of response PKEY sId vr (chain, key) -> do case supportedClientSMPRelayVRange `compatibleVersion` vr of Nothing -> throwE PCEIncompatibleHost -- TODO different error - Just (Compatible v) -> liftEitherWith x509Error $ (sId,v,) <$> validateRelay chain key + Just (Compatible v) -> liftEitherWith x509Error $ ProxiedRelay sId v <$> validateRelay chain key r -> throwE . PCEUnexpectedResponse $ bshow r where x509Error :: String -> SMPClientError @@ -672,6 +690,12 @@ createSMPProxySession c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyA pubKey <- C.verifyX509 serverKey exact C.x509ToPublic (pubKey, []) >>= C.pubKey +data ProxiedRelay = ProxiedRelay + { prSessionId :: SessionId, + prVersion :: VersionSMP, + prServerKey :: C.PublicKeyX25519 + } + -- consider how to process slow responses - is it handled somehow locally or delegated to the caller -- this method is used in the client -- sends PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command Sender @@ -679,9 +703,7 @@ createSMPProxySession c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyA proxySMPMessage :: SMPClient -> -- proxy session from PKEY - SessionId -> - VersionSMP -> - C.PublicKeyX25519 -> + ProxiedRelay -> -- message to deliver Maybe SndPrivateAuthKey -> SenderId -> @@ -689,7 +711,7 @@ proxySMPMessage :: MsgBody -> ExceptT SMPClientError IO () -- TODO use version -proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} sessionId _v serverKey spKey sId flags msg = do +proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} (ProxiedRelay sessionId _v serverKey) spKey sId flags msg = do -- prepare params let serverThAuth = (\ta -> ta {serverPeerPubKey = serverKey}) <$> thAuth proxyThParams serverThParams = proxyThParams {sessionId, thAuth = serverThAuth} @@ -867,4 +889,6 @@ $(J.deriveJSON (enumJSON $ dropPrefix "HM") ''HostMode) $(J.deriveJSON (enumJSON $ dropPrefix "TSM") ''TransportSessionMode) +$(J.deriveJSON (enumJSON $ dropPrefix "SPM") ''SMPProxyMode) + $(J.deriveJSON defaultJSON ''NetworkConfig) diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index 8083ef988..b14917c18 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -13,7 +13,7 @@ module AgentTests (agentTests) where import AgentTests.ConnectionRequestTests import AgentTests.DoubleRatchetTests (doubleRatchetTests) -import AgentTests.FunctionalAPITests (functionalAPITests, inAnyOrder, pattern Msg, pattern Msg') +import AgentTests.FunctionalAPITests (functionalAPITests, inAnyOrder, pattern Msg, pattern Msg', pattern SENT) import AgentTests.MigrationTests (migrationTests) import AgentTests.NotificationTests (notificationTests) import AgentTests.SQLiteTests (storeTests) @@ -27,7 +27,7 @@ import GHC.Stack (withFrozenCallStack) import Network.HTTP.Types (urlEncode) import SMPAgentClient import SMPClient (testKeyHash, testPort, testPort2, testStoreLogFile, withSmpServer, withSmpServerStoreLogOn) -import Simplex.Messaging.Agent.Protocol hiding (MID, CONF, INFO, REQ) +import Simplex.Messaging.Agent.Protocol hiding (MID, CONF, INFO, REQ, SENT) import qualified Simplex.Messaging.Agent.Protocol as A import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOn, pattern IKPQOff, pattern PQEncOn, pattern PQSupportOn, pattern PQSupportOff) import qualified Simplex.Messaging.Crypto.Ratchet as CR @@ -437,8 +437,8 @@ testServerConnectionAfterError t _ = do bob #: ("1", "alice", "SUB") =#> \("1", "alice", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT alice #: ("1", "bob", "SUB") =#> \("1", "bob", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT withServer $ do - alice <#=? \case ("", "bob", APC _ (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False - alice <#=? \case ("", "bob", APC _ (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False + alice <#=? \case ("", "bob", APC SAEConn (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False + alice <#=? \case ("", "bob", APC SAEConn (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False bob <#=? \case ("", "alice", APC _ (Msg "hello")) -> True; ("", "", APC _ (UP s ["alice"])) -> s == server; _ -> False bob <#=? \case ("", "alice", APC _ (Msg "hello")) -> True; ("", "", APC _ (UP s ["alice"])) -> s == server; _ -> False bob #: ("2", "alice", "ACK 4") #> ("2", "alice", OK) diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 301be97b4..13822650f 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -45,6 +45,7 @@ module AgentTests.FunctionalAPITests pattern REQ, pattern Msg, pattern Msg', + pattern SENT, agentCfgV7, ) where @@ -70,17 +71,17 @@ import Data.Word (Word16) import qualified Database.SQLite.Simple as SQL import GHC.Stack (withFrozenCallStack) import SMPAgentClient -import SMPClient (cfg, testPort, testPort2, testStoreLogFile2, withSmpServer, withSmpServerConfigOn, withSmpServerOn, withSmpServerStoreLogOn, withSmpServerStoreMsgLogOn, withSmpServerV7) +import SMPClient (cfg, testPort, testPort2, testStoreLogFile2, withSmpServer, withSmpServerConfigOn, withSmpServerOn, withSmpServerProxy, withSmpServerStoreLogOn, withSmpServerStoreMsgLogOn, withSmpServerV7) import Simplex.Messaging.Agent hiding (createConnection, joinConnection, sendMessage) import qualified Simplex.Messaging.Agent as A import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), UserNetworkInfo (..), UserNetworkType (..), waitForUserNetwork) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore) -import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO, REQ) +import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO, REQ, SENT) import qualified Simplex.Messaging.Agent.Protocol as A import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') -import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), TransportSessionMode (TSMEntity, TSMUser), defaultSMPClientConfig) +import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), SMPProxyMode (..), TransportSessionMode (TSMEntity, TSMUser), defaultSMPClientConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR @@ -171,6 +172,9 @@ pattern MsgErr msgId err msgBody <- MSG MsgMeta {recipient = (msgId, _), integri pattern MsgErr' :: AgentMsgId -> MsgErrorType -> PQEncryption -> MsgBody -> ACommand 'Agent 'AEConn pattern MsgErr' msgId err pq msgBody <- MSG MsgMeta {recipient = (msgId, _), integrity = MsgError err, pqEncryption = pq} _ msgBody +pattern SENT :: AgentMsgId -> ACommand 'Agent 'AEConn +pattern SENT msgId = A.SENT msgId Nothing + pattern Rcvd :: AgentMsgId -> ACommand 'Agent 'AEConn pattern Rcvd agentMsgId <- RCVD MsgMeta {integrity = MsgOk} [MsgReceipt {agentMsgId, msgRcptStatus = MROk}] @@ -448,26 +452,28 @@ canCreateQueue allowNew (srvAuth, srvVersion) (clntAuth, clntVersion) = let v = basicAuthSMPVersion in allowNew && (isNothing srvAuth || (srvVersion >= v && clntVersion >= v && srvAuth == clntAuth)) -testMatrix2 :: ATransport -> (PQSupport -> AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> Spec +testMatrix2 :: ATransport -> (PQSupport -> Bool -> AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> Spec testMatrix2 t runTest = do - it "v7" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfgV7 3 $ runTest PQSupportOn - it "v7 to current" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfg 3 $ runTest PQSupportOn - it "current to v7" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfgV7 3 $ runTest PQSupportOn - it "current with v7 server" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfg 3 $ runTest PQSupportOn - it "current" $ withSmpServer t $ runTestCfg2 agentCfg agentCfg 3 $ runTest PQSupportOn - it "prev" $ withSmpServer t $ runTestCfg2 agentCfgVPrev agentCfgVPrev 3 $ runTest PQSupportOff - it "prev to current" $ withSmpServer t $ runTestCfg2 agentCfgVPrev agentCfg 3 $ runTest PQSupportOff - it "current to prev" $ withSmpServer t $ runTestCfg2 agentCfg agentCfgVPrev 3 $ runTest PQSupportOff + it "v8, via proxy" $ withSmpServerProxy t $ runTestCfgServers2 agentProxyCfg agentProxyCfg (initAgentServersProxy SPMAlways) 3 $ runTest PQSupportOn True + it "v7" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfgV7 3 $ runTest PQSupportOn False + it "v7 to current" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfg 3 $ runTest PQSupportOn False + it "current to v7" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfgV7 3 $ runTest PQSupportOn False + it "current with v7 server" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfg 3 $ runTest PQSupportOn False + it "current" $ withSmpServer t $ runTestCfg2 agentCfg agentCfg 3 $ runTest PQSupportOn False + it "prev" $ withSmpServer t $ runTestCfg2 agentCfgVPrev agentCfgVPrev 3 $ runTest PQSupportOff False + it "prev to current" $ withSmpServer t $ runTestCfg2 agentCfgVPrev agentCfg 3 $ runTest PQSupportOff False + it "current to prev" $ withSmpServer t $ runTestCfg2 agentCfg agentCfgVPrev 3 $ runTest PQSupportOff False -testRatchetMatrix2 :: ATransport -> (PQSupport -> AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> Spec +testRatchetMatrix2 :: ATransport -> (PQSupport -> Bool -> AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> Spec testRatchetMatrix2 t runTest = do - it "ratchet next" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfgV7 3 $ runTest PQSupportOn - it "ratchet next to current" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfg 3 $ runTest PQSupportOn - it "ratchet current to next" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfgV7 3 $ runTest PQSupportOn - it "ratchet current" $ withSmpServer t $ runTestCfg2 agentCfg agentCfg 3 $ runTest PQSupportOn - it "ratchet prev" $ withSmpServer t $ runTestCfg2 agentCfgRatchetVPrev agentCfgRatchetVPrev 3 $ runTest PQSupportOff - it "ratchets prev to current" $ withSmpServer t $ runTestCfg2 agentCfgRatchetVPrev agentCfg 3 $ runTest PQSupportOff - it "ratchets current to prev" $ withSmpServer t $ runTestCfg2 agentCfg agentCfgRatchetVPrev 3 $ runTest PQSupportOff + it "v8, via proxy" $ withSmpServerProxy t $ runTestCfgServers2 agentProxyCfg agentProxyCfg (initAgentServersProxy SPMAlways) 3 $ runTest PQSupportOn True + it "ratchet next" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfgV7 3 $ runTest PQSupportOn False + it "ratchet next to current" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfg 3 $ runTest PQSupportOn False + it "ratchet current to next" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfgV7 3 $ runTest PQSupportOn False + it "ratchet current" $ withSmpServer t $ runTestCfg2 agentCfg agentCfg 3 $ runTest PQSupportOn False + it "ratchet prev" $ withSmpServer t $ runTestCfg2 agentCfgRatchetVPrev agentCfgRatchetVPrev 3 $ runTest PQSupportOff False + it "ratchets prev to current" $ withSmpServer t $ runTestCfg2 agentCfgRatchetVPrev agentCfg 3 $ runTest PQSupportOff False + it "ratchets current to prev" $ withSmpServer t $ runTestCfg2 agentCfg agentCfgRatchetVPrev 3 $ runTest PQSupportOff False testServerMatrix2 :: ATransport -> (InitialAgentServers -> IO ()) -> Spec testServerMatrix2 t runTest = do @@ -475,10 +481,14 @@ testServerMatrix2 t runTest = do it "2 servers" $ withSmpServer t . withSmpServerOn t testPort2 $ runTest initAgentServers2 runTestCfg2 :: HasCallStack => AgentConfig -> AgentConfig -> AgentMsgId -> (HasCallStack => AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> IO () -runTestCfg2 aCfg bCfg baseMsgId runTest = - withAgentClientsCfg2 aCfg bCfg $ \a b -> runTest a b baseMsgId +runTestCfg2 aCfg bCfg = runTestCfgServers2 aCfg bCfg initAgentServers {-# INLINE runTestCfg2 #-} +runTestCfgServers2 :: HasCallStack => AgentConfig -> AgentConfig -> InitialAgentServers -> AgentMsgId -> (HasCallStack => AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> IO () +runTestCfgServers2 aCfg bCfg servers baseMsgId runTest = + withAgentClientsCfgServers2 aCfg bCfg servers $ \a b -> runTest a b baseMsgId +{-# INLINE runTestCfgServers2 #-} + withAgentClientsCfgServers2 :: HasCallStack => AgentConfig -> AgentConfig -> InitialAgentServers -> (HasCallStack => AgentClient -> AgentClient -> IO ()) -> IO () withAgentClientsCfgServers2 aCfg bCfg servers runTest = withAgent 1 aCfg servers testDB $ \a -> @@ -499,8 +509,8 @@ withAgentClients3 runTest = withAgent 3 agentCfg initAgentServers testDB3 $ \c -> runTest a b c -runAgentClientTest :: HasCallStack => PQSupport -> AgentClient -> AgentClient -> AgentMsgId -> IO () -runAgentClientTest pqSupport alice@AgentClient {} bob baseId = +runAgentClientTest :: HasCallStack => PQSupport -> Bool -> AgentClient -> AgentClient -> AgentMsgId -> IO () +runAgentClientTest pqSupport viaProxy alice@AgentClient {} bob baseId = runRight_ $ do (bobId, qInfo) <- A.createConnection alice 1 True SCMInvitation Nothing (IKNoPQ pqSupport) SMSubscribe aliceId <- A.joinConnection bob 1 True qInfo "bob's connInfo" pqSupport SMSubscribe @@ -512,18 +522,19 @@ runAgentClientTest pqSupport alice@AgentClient {} bob baseId = get bob ##> ("", aliceId, A.INFO pqSupport "alice's connInfo") get bob ##> ("", aliceId, A.CON pqEnc) -- message IDs 1 to 3 (or 1 to 4 in v1) get assigned to control messages, so first MSG is assigned ID 4 + let proxySrv = if viaProxy then Just testSMPServer else Nothing 1 <- msgId <$> A.sendMessage alice bobId pqEnc SMP.noMsgFlags "hello" - get alice ##> ("", bobId, SENT $ baseId + 1) + get alice ##> ("", bobId, A.SENT (baseId + 1) proxySrv) 2 <- msgId <$> A.sendMessage alice bobId pqEnc SMP.noMsgFlags "how are you?" - get alice ##> ("", bobId, SENT $ baseId + 2) + get alice ##> ("", bobId, A.SENT (baseId + 2) proxySrv) get bob =##> \case ("", c, Msg' _ pq "hello") -> c == aliceId && pq == pqEnc; _ -> False ackMessage bob aliceId (baseId + 1) Nothing get bob =##> \case ("", c, Msg' _ pq "how are you?") -> c == aliceId && pq == pqEnc; _ -> False ackMessage bob aliceId (baseId + 2) Nothing 3 <- msgId <$> A.sendMessage bob aliceId pqEnc SMP.noMsgFlags "hello too" - get bob ##> ("", aliceId, SENT $ baseId + 3) + get bob ##> ("", aliceId, A.SENT (baseId + 3) proxySrv) 4 <- msgId <$> A.sendMessage bob aliceId pqEnc SMP.noMsgFlags "message 1" - get bob ##> ("", aliceId, SENT $ baseId + 4) + get bob ##> ("", aliceId, A.SENT (baseId + 4) proxySrv) get alice =##> \case ("", c, Msg' _ pq "hello too") -> c == bobId && pq == pqEnc; _ -> False ackMessage alice bobId (baseId + 3) Nothing get alice =##> \case ("", c, Msg' _ pq "message 1") -> c == bobId && pq == pqEnc; _ -> False @@ -627,8 +638,8 @@ testAgentClient3 = get c =##> \case ("", connId, Msg "c5") -> connId == aIdForC; _ -> False ackMessage c aIdForC 5 Nothing -runAgentClientContactTest :: HasCallStack => PQSupport -> AgentClient -> AgentClient -> AgentMsgId -> IO () -runAgentClientContactTest pqSupport alice bob baseId = +runAgentClientContactTest :: HasCallStack => PQSupport -> Bool -> AgentClient -> AgentClient -> AgentMsgId -> IO () +runAgentClientContactTest pqSupport viaProxy alice bob baseId = runRight_ $ do (_, qInfo) <- A.createConnection alice 1 True SCMContact Nothing (IKNoPQ pqSupport) SMSubscribe aliceId <- A.joinConnection bob 1 True qInfo "bob's connInfo" pqSupport SMSubscribe @@ -643,18 +654,19 @@ runAgentClientContactTest pqSupport alice bob baseId = get alice ##> ("", bobId, A.CON pqEnc) get bob ##> ("", aliceId, A.CON pqEnc) -- message IDs 1 to 3 (or 1 to 4 in v1) get assigned to control messages, so first MSG is assigned ID 4 + let proxySrv = if viaProxy then Just testSMPServer else Nothing 1 <- msgId <$> A.sendMessage alice bobId pqEnc SMP.noMsgFlags "hello" - get alice ##> ("", bobId, SENT $ baseId + 1) + get alice ##> ("", bobId, A.SENT (baseId + 1) proxySrv) 2 <- msgId <$> A.sendMessage alice bobId pqEnc SMP.noMsgFlags "how are you?" - get alice ##> ("", bobId, SENT $ baseId + 2) + get alice ##> ("", bobId, A.SENT (baseId + 2) proxySrv) get bob =##> \case ("", c, Msg' _ pq "hello") -> c == aliceId && pq == pqEnc; _ -> False ackMessage bob aliceId (baseId + 1) Nothing get bob =##> \case ("", c, Msg' _ pq "how are you?") -> c == aliceId && pq == pqEnc; _ -> False ackMessage bob aliceId (baseId + 2) Nothing 3 <- msgId <$> A.sendMessage bob aliceId pqEnc SMP.noMsgFlags "hello too" - get bob ##> ("", aliceId, SENT $ baseId + 3) + get bob ##> ("", aliceId, A.SENT (baseId + 3) proxySrv) 4 <- msgId <$> A.sendMessage bob aliceId pqEnc SMP.noMsgFlags "message 1" - get bob ##> ("", aliceId, SENT $ baseId + 4) + get bob ##> ("", aliceId, A.SENT (baseId + 4) proxySrv) get alice =##> \case ("", c, Msg' _ pq "hello too") -> c == bobId && pq == pqEnc; _ -> False ackMessage alice bobId (baseId + 3) Nothing get alice =##> \case ("", c, Msg' _ pq "message 1") -> c == bobId && pq == pqEnc; _ -> False @@ -1493,9 +1505,9 @@ testSuspendingAgentCompleteSending t = withAgentClients2 $ \a b -> do liftIO $ suspendAgent b 5000000 withSmpServerStoreLogOn t testPort $ \_ -> runRight_ @AgentErrorType $ do - pGet b =##> \case ("", c, APC _ (SENT 5)) -> c == aId; ("", "", APC _ UP {}) -> True; _ -> False - pGet b =##> \case ("", c, APC _ (SENT 5)) -> c == aId; ("", "", APC _ UP {}) -> True; _ -> False - pGet b =##> \case ("", c, APC _ (SENT 6)) -> c == aId; ("", "", APC _ UP {}) -> True; _ -> False + pGet b =##> \case ("", c, APC SAEConn (SENT 5)) -> c == aId; ("", "", APC _ UP {}) -> True; _ -> False + pGet b =##> \case ("", c, APC SAEConn (SENT 5)) -> c == aId; ("", "", APC _ UP {}) -> True; _ -> False + pGet b =##> \case ("", c, APC SAEConn (SENT 6)) -> c == aId; ("", "", APC _ UP {}) -> True; _ -> False ("", "", SUSPENDED) <- nGet b pGet a =##> \case ("", c, APC _ (Msg "hello too")) -> c == bId; ("", "", APC _ UP {}) -> True; _ -> False diff --git a/tests/AgentTests/NotificationTests.hs b/tests/AgentTests/NotificationTests.hs index 2c1045791..b4f6ec3ee 100644 --- a/tests/AgentTests/NotificationTests.hs +++ b/tests/AgentTests/NotificationTests.hs @@ -6,8 +6,8 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeApplications #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} +{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module AgentTests.NotificationTests where @@ -17,10 +17,6 @@ import AgentTests.FunctionalAPITests createConnection, exchangeGreetingsMsgId, get, - withAgent, - withAgentClients2, - withAgentClientsCfgServers2, - withAgentClients3, joinConnection, makeConnection, nGet, @@ -29,13 +25,18 @@ import AgentTests.FunctionalAPITests sendMessage, switchComplete, testServerMatrix2, + withAgent, + withAgentClients2, + withAgentClients3, withAgentClientsCfg2, + withAgentClientsCfgServers2, (##>), (=##>), pattern CON, pattern CONF, pattern INFO, pattern Msg, + pattern SENT, ) import Control.Concurrent (ThreadId, killThread, threadDelay) import Control.Monad @@ -55,12 +56,12 @@ import SMPClient (cfg, cfgV7, testPort, testPort2, testStoreLogFile2, withSmpSer import Simplex.Messaging.Agent hiding (createConnection, joinConnection, sendMessage) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), withStore') import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, Env (..), InitialAgentServers) -import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO) +import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO, SENT) import Simplex.Messaging.Agent.Store.SQLite (getSavedNtfToken) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..)) import Simplex.Messaging.Notifications.Protocol +import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..)) import Simplex.Messaging.Notifications.Server.Push.APNS import Simplex.Messaging.Notifications.Types (NtfToken (..)) import Simplex.Messaging.Protocol (ErrorType (AUTH), MsgFlags (MsgFlags), NtfServer, ProtocolServer (..), SMPMsgMeta (..), SubscriptionMode (..)) @@ -151,7 +152,8 @@ testNtfMatrix t runTest = do it "next servers: SMP v7, NTF v2; curr clients: v6/v1" $ runNtfTestCfg t cfgV7 ntfServerCfgV2 agentCfg agentCfg runTest it "curr servers: SMP v6, NTF v1; curr clients: v6/v1" $ runNtfTestCfg t cfg ntfServerCfg agentCfg agentCfg runTest skip "this case cannot be supported - see RFC" $ - it "servers: SMP v6, NTF v1; clients: v7/v2 (not supported)" $ runNtfTestCfg t cfg ntfServerCfg agentCfgV7 agentCfgV7 runTest + it "servers: SMP v6, NTF v1; clients: v7/v2 (not supported)" $ + runNtfTestCfg t cfg ntfServerCfg agentCfgV7 agentCfgV7 runTest -- servers can be migrated in any order it "servers: next SMP v7, curr NTF v1; curr clients: v6/v1" $ runNtfTestCfg t cfgV7 ntfServerCfg agentCfg agentCfg runTest it "servers: curr SMP v6, next NTF v2; curr clients: v6/v1" $ runNtfTestCfg t cfg ntfServerCfgV2 agentCfg agentCfg runTest @@ -258,7 +260,7 @@ testNtfTokenServerRestart t APNSMockServer {apnsQ} = do atomically $ readTBQueue apnsQ liftIO $ sendApnsResponse APNSRespOk pure ntfData - -- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server + -- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server threadDelay 1000000 withAgent 2 agentCfg initAgentServers testDB $ \a' -> -- server stopped before token is verified, so now the attempt to verify it will return AUTH error but re-register token, diff --git a/tests/SMPAgentClient.hs b/tests/SMPAgentClient.hs index 59370e654..d509042f0 100644 --- a/tests/SMPAgentClient.hs +++ b/tests/SMPAgentClient.hs @@ -20,7 +20,8 @@ import qualified Database.SQLite.Simple as SQL import Network.Socket (ServiceName) import NtfClient (ntfTestPort) import SMPClient - ( serverBracket, + ( proxyVRange, + serverBracket, testKeyHash, testPort, testPort2, @@ -34,7 +35,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Server (runSMPAgentBlocking) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') -import Simplex.Messaging.Client (ProtocolClientConfig (..), chooseTransportHost, defaultSMPClientConfig, defaultNetworkConfig) +import Simplex.Messaging.Client (ProtocolClientConfig (..), SMPProxyMode, chooseTransportHost, defaultSMPClientConfig, defaultNetworkConfig) import Simplex.Messaging.Notifications.Client (defaultNTFClientConfig) import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (NtfServer, ProtoServerWithAuth) @@ -198,6 +199,9 @@ initAgentServers = initAgentServers2 :: InitialAgentServers initAgentServers2 = initAgentServers {smp = userServers [noAuthSrv testSMPServer, noAuthSrv testSMPServer2]} +initAgentServersProxy :: SMPProxyMode -> InitialAgentServers +initAgentServersProxy smpProxyMode = initAgentServers {netCfg = (netCfg initAgentServers) {smpProxyMode}} + agentCfg :: AgentConfig agentCfg = defaultAgentConfig @@ -217,6 +221,9 @@ agentCfg = where networkConfig = defaultNetworkConfig {tcpConnectTimeout = 3_000_000, tcpTimeout = 2_000_000} +agentProxyCfg :: AgentConfig +agentProxyCfg = agentCfg {smpCfg = (smpCfg agentCfg) {serverVRange = proxyVRange}} + fastRetryInterval :: RetryInterval fastRetryInterval = defaultReconnectInterval {initialInterval = 50_000} diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index ad4d00266..e27970608 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -123,9 +123,12 @@ proxyCfg = cfgV7 { allowSMPProxy = True, smpServerVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, - smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, agreeSecret = True}} + smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = proxyVRange, agreeSecret = True}} } +proxyVRange :: VersionRangeSMP +proxyVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion + withSmpServerStoreMsgLogOn :: HasCallStack => ATransport -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a withSmpServerStoreMsgLogOn t = withSmpServerConfigOn t cfg {storeLogFile = Just testStoreLogFile, storeMsgsFile = Just testStoreMsgsFile, serverStatsBackupFile = Just testServerStatsBackupFile} @@ -163,6 +166,9 @@ withSmpServer t = withSmpServerOn t testPort withSmpServerV7 :: HasCallStack => ATransport -> IO a -> IO a withSmpServerV7 t = withSmpServerConfigOn t cfgV7 testPort . const +withSmpServerProxy :: HasCallStack => ATransport -> IO a -> IO a +withSmpServerProxy t = withSmpServerConfigOn t proxyCfg testPort . const + runSmpTest :: forall c a. (HasCallStack, Transport c) => (HasCallStack => THandleSMP c 'TClient -> IO a) -> IO a runSmpTest test = withSmpServer (transport @c) $ testSMPClient test diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 2a33ec055..ae2a05e4d 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -1,8 +1,11 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -10,15 +13,23 @@ module SMPProxyTests where -import AgentTests.FunctionalAPITests (runRight_) +import AgentTests.FunctionalAPITests import Data.ByteString.Char8 (ByteString) -import SMPAgentClient (testSMPServer, testSMPServer2) +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import SMPAgentClient import SMPClient -import qualified SMPClient as SMP import ServerTests (decryptMsgV3, sendRecv) +import Simplex.Messaging.Agent hiding (createConnection, joinConnection, sendMessage) +import qualified Simplex.Messaging.Agent as A +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..)) +import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO, REQ) +import qualified Simplex.Messaging.Agent.Protocol as A import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Protocol +import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOn) +import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) import Simplex.Messaging.Transport import Simplex.Messaging.Version (mkVersionRange) @@ -37,49 +48,52 @@ smpProxyTests = do xit "no SMP service at host/port" todo xit "bad SMP fingerprint" todo xit "batching proxy requests" todo - describe "forwarding requests" $ do - describe "deliver message via SMP proxy" $ do + describe "deliver message via SMP proxy" $ do + let srv1 = SMPServer testHost testPort testKeyHash + srv2 = SMPServer testHost testPort2 testKeyHash + describe "client API" $ do let maxLen = maxMessageLength sendingProxySMPVersion - it "same server" $ - withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> do - let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash - let relayServ = proxyServ + describe "one server" $ do + it "deliver via proxy" . oneServer $ do + deliverMessageViaProxy srv1 srv1 C.SEd448 "hello 1" "hello 2" + describe "two servers" $ do + let proxyServ = srv1 + relayServ = srv2 + (msg1, msg2) <- runIO $ do + g <- C.newRandom + atomically $ (,) <$> C.randomBytes maxLen g <*> C.randomBytes maxLen g + it "deliver via proxy" . twoServersFirstProxy $ deliverMessageViaProxy proxyServ relayServ C.SEd448 "hello 1" "hello 2" - it "different servers" $ - withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> - withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do - let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash - let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash - deliverMessageViaProxy proxyServ relayServ C.SEd448 "hello 1" "hello 2" - it "max message size, Ed448 keys" $ - withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> - withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do - g <- C.newRandom - msg <- atomically $ C.randomBytes maxLen g - msg' <- atomically $ C.randomBytes maxLen g - let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash - let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash - deliverMessageViaProxy proxyServ relayServ C.SEd448 msg msg' - it "max message size, Ed25519 keys" $ - withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> - withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do - g <- C.newRandom - msg <- atomically $ C.randomBytes maxLen g - msg' <- atomically $ C.randomBytes maxLen g - let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash - let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash - deliverMessageViaProxy proxyServ relayServ C.SEd25519 msg msg' - it "max message size, X25519 keys" $ - withSmpServerConfigOn (transport @TLS) proxyCfg testPort $ \_ -> - withSmpServerConfigOn (transport @TLS) cfgV7 testPort2 $ \_ -> do - g <- C.newRandom - msg <- atomically $ C.randomBytes maxLen g - msg' <- atomically $ C.randomBytes maxLen g - let proxyServ = SMPServer SMP.testHost SMP.testPort SMP.testKeyHash - let relayServ = SMPServer SMP.testHost SMP.testPort2 SMP.testKeyHash - deliverMessageViaProxy proxyServ relayServ C.SX25519 msg msg' - xit "sender-proxy-relay-recipient works" todo - xit "similar timing for proxied and direct sends" todo + it "max message size, Ed448 keys" . twoServersFirstProxy $ + deliverMessageViaProxy proxyServ relayServ C.SEd448 msg1 msg2 + it "max message size, Ed25519 keys" . twoServersFirstProxy $ + deliverMessageViaProxy proxyServ relayServ C.SEd25519 msg1 msg2 + it "max message size, X25519 keys" . twoServersFirstProxy $ + deliverMessageViaProxy proxyServ relayServ C.SX25519 msg1 msg2 + describe "agent API" $ do + describe "one server" $ do + it "always via proxy" . oneServer $ + agentDeliverMessageViaProxy ([srv1], SPMAlways, True) ([srv1], SPMAlways, True) C.SEd448 "hello 1" "hello 2" + it "without proxy" . oneServer $ + agentDeliverMessageViaProxy ([srv1], SPMNever, False) ([srv1], SPMNever, False) C.SEd448 "hello 1" "hello 2" + describe "two servers" $ do + it "always via proxy" . twoServers $ + agentDeliverMessageViaProxy ([srv1], SPMAlways, True) ([srv2], SPMAlways, True) C.SEd448 "hello 1" "hello 2" + it "both via proxy" . twoServers $ + agentDeliverMessageViaProxy ([srv1], SPMUnknown, True) ([srv2], SPMUnknown, True) C.SEd448 "hello 1" "hello 2" + it "first via proxy" . twoServers $ + agentDeliverMessageViaProxy ([srv1], SPMUnknown, True) ([srv2], SPMNever, False) C.SEd448 "hello 1" "hello 2" + it "without proxy" . twoServers $ + agentDeliverMessageViaProxy ([srv1], SPMNever, False) ([srv2], SPMNever, False) C.SEd448 "hello 1" "hello 2" + it "first via proxy for unknown" . twoServers $ + agentDeliverMessageViaProxy ([srv1], SPMUnknown, True) ([srv1, srv2], SPMUnknown, False) C.SEd448 "hello 1" "hello 2" + where + oneServer = withSmpServerConfigOn (transport @TLS) proxyCfg testPort . const + twoServers = twoServers_ proxyCfg proxyCfg + twoServersFirstProxy = twoServers_ proxyCfg cfgV7 + twoServers_ cfg1 cfg2 runTest = + withSmpServerConfigOn (transport @TLS) cfg1 testPort $ \_ -> + withSmpServerConfigOn (transport @TLS) cfg2 testPort2 $ const runTest deliverMessageViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => SMPServer -> SMPServer -> C.SAlgorithm a -> ByteString -> ByteString -> IO () deliverMessageViaProxy proxyServ relayServ alg msg msg' = do @@ -97,39 +111,74 @@ deliverMessageViaProxy proxyServ relayServ alg msg msg' = do QIK {rcvId, sndId, rcvPublicDhKey = srvDh} <- createSMPQueue rc (rPub, rPriv) rdhPub (Just "correct") SMSubscribe let dec = decryptMsgV3 $ C.dh' srvDh rdhPriv -- get proxy session - (sessId, v, relayKey) <- createSMPProxySession pc relayServ (Just "correct") + sess <- connectSMPProxiedRelay pc relayServ (Just "correct") -- send via proxy to unsecured queue - proxySMPMessage pc sessId v relayKey Nothing sndId noMsgFlags msg + proxySMPMessage pc sess Nothing sndId noMsgFlags msg -- receive 1 - (_tSess, _v, _sid, _ety, MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody}) <- atomically $ readTBQueue msgQ + (_tSess, _v, _sid, _ety, SMP.MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody}) <- atomically $ readTBQueue msgQ liftIO $ dec msgId encBody `shouldBe` Right msg ackSMPMessage rc rPriv rcvId msgId -- secure queue (sPub, sPriv) <- atomically $ C.generateAuthKeyPair alg g secureSMPQueue rc rPriv rcvId sPub -- send via proxy to secured queue - proxySMPMessage pc sessId v relayKey (Just sPriv) sndId noMsgFlags msg' + proxySMPMessage pc sess (Just sPriv) sndId noMsgFlags msg' -- receive 2 - (_tSess, _v, _sid, _ety, MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'}) <- atomically $ readTBQueue msgQ + (_tSess, _v, _sid, _ety, SMP.MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'}) <- atomically $ readTBQueue msgQ liftIO $ dec msgId' encBody' `shouldBe` Right msg' ackSMPMessage rc rPriv rcvId msgId' -proxyVRange :: VersionRangeSMP -proxyVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion +agentDeliverMessageViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => (NonEmpty SMPServer, SMPProxyMode, Bool) -> (NonEmpty SMPServer, SMPProxyMode, Bool) -> C.SAlgorithm a -> ByteString -> ByteString -> IO () +agentDeliverMessageViaProxy aTestCfg@(aSrvs, _, aViaProxy) bTestCfg@(bSrvs, _, bViaProxy) alg msg1 msg2 = + withAgent 1 aCfg (servers aTestCfg) testDB $ \alice -> + withAgent 2 aCfg (servers bTestCfg) testDB2 $ \bob -> runRight_ $ do + (bobId, qInfo) <- A.createConnection alice 1 True SCMInvitation Nothing (CR.IKNoPQ PQSupportOn) SMSubscribe + aliceId <- A.joinConnection bob 1 True qInfo "bob's connInfo" PQSupportOn SMSubscribe + ("", _, A.CONF confId pqSup' _ "bob's connInfo") <- get alice + liftIO $ pqSup' `shouldBe` PQSupportOn + allowConnection alice bobId confId "alice's connInfo" + let pqEnc = CR.PQEncOn + get alice ##> ("", bobId, A.CON pqEnc) + get bob ##> ("", aliceId, A.INFO PQSupportOn "alice's connInfo") + get bob ##> ("", aliceId, A.CON pqEnc) + -- message IDs 1 to 3 (or 1 to 4 in v1) get assigned to control messages, so first MSG is assigned ID 4 + let aProxySrv = if aViaProxy then Just $ L.head aSrvs else Nothing + 1 <- msgId <$> A.sendMessage alice bobId pqEnc noMsgFlags msg1 + get alice ##> ("", bobId, A.SENT (baseId + 1) aProxySrv) + 2 <- msgId <$> A.sendMessage alice bobId pqEnc noMsgFlags msg2 + get alice ##> ("", bobId, A.SENT (baseId + 2) aProxySrv) + get bob =##> \case ("", c, Msg' _ pq msg1') -> c == aliceId && pq == pqEnc && msg1 == msg1'; _ -> False + ackMessage bob aliceId (baseId + 1) Nothing + get bob =##> \case ("", c, Msg' _ pq msg2') -> c == aliceId && pq == pqEnc && msg2 == msg2'; _ -> False + ackMessage bob aliceId (baseId + 2) Nothing + let bProxySrv = if bViaProxy then Just $ L.head bSrvs else Nothing + 3 <- msgId <$> A.sendMessage bob aliceId pqEnc noMsgFlags msg1 + get bob ##> ("", aliceId, A.SENT (baseId + 3) bProxySrv) + 4 <- msgId <$> A.sendMessage bob aliceId pqEnc noMsgFlags msg2 + get bob ##> ("", aliceId, A.SENT (baseId + 4) bProxySrv) + get alice =##> \case ("", c, Msg' _ pq msg1') -> c == bobId && pq == pqEnc && msg1 == msg1'; _ -> False + ackMessage alice bobId (baseId + 3) Nothing + get alice =##> \case ("", c, Msg' _ pq msg2') -> c == bobId && pq == pqEnc && msg2 == msg2'; _ -> False + ackMessage alice bobId (baseId + 4) Nothing + where + baseId = 3 + msgId = subtract baseId . fst + aCfg = agentProxyCfg {sndAuthAlg = C.AuthAlg alg, rcvAuthAlg = C.AuthAlg alg} + servers (srvs, smpProxyMode, _) = (initAgentServersProxy smpProxyMode) {smp = userServers $ L.map noAuthSrv srvs} testNoProxy :: IO () testNoProxy = do withSmpServerConfigOn (transport @TLS) cfg testPort2 $ \_ -> do testSMPClient_ "127.0.0.1" testPort2 proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer Nothing) - reply `shouldBe` Right (ERR AUTH) + reply `shouldBe` Right (SMP.ERR SMP.AUTH) testProxyAuth :: IO () testProxyAuth = do withSmpServerConfigOn (transport @TLS) proxyCfgAuth testPort $ \_ -> do testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, _s, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 $ Just "wrong") - reply `shouldBe` Right (ERR AUTH) + reply `shouldBe` Right (SMP.ERR SMP.AUTH) where proxyCfgAuth = proxyCfg {newQueueBasicAuth = Just "correct"} From 0d2c1d3c0103d72b19693554ccf97c3f60eeac74 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 1 May 2024 12:38:33 +0300 Subject: [PATCH 025/107] transport: reduce ping traffic (#1118) * transport: reduce ping traffic * make pings opt-in, enable automatically with SUB commands * fix reduced delays * enable pings on MSG too * rename pingErrorCount * check timeout counter even when not sending pings * clean up * reset timeout error count on any event --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Client.hs | 82 ++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 454df91e8..f54fe686f 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -97,7 +97,7 @@ import Data.List (find) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) -import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime) import Network.Socket (ServiceName) import Numeric.Natural import qualified Simplex.Messaging.Crypto as C @@ -110,7 +110,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (SocksProxy, TransportClientConfig (..), TransportHost (..), defaultTcpConnectTimeout, runTransportClient) import Simplex.Messaging.Transport.KeepAlive import Simplex.Messaging.Transport.WebSockets (WS) -import Simplex.Messaging.Util (bshow, raceAny_, threadDelay', whenM) +import Simplex.Messaging.Util (bshow, diffToMicroseconds, raceAny_, threadDelay', whenM) import Simplex.Messaging.Version import System.Timeout (timeout) import UnliftIO (pooledMapConcurrentlyN) @@ -131,7 +131,9 @@ data PClient v err msg = PClient transportHost :: TransportHost, tcpTimeout :: Int, rcvConcurrency :: Int, - pingErrorCount :: TVar Int, + sendPings :: TVar Bool, + lastReceived :: TVar UTCTime, + timeoutErrorCount :: TVar Int, clientCorrId :: TVar ChaChaDRG, sentCommands :: TMap CorrId (Request err msg), sndQ :: TBQueue (TVar Bool, ByteString), @@ -141,10 +143,13 @@ data PClient v err msg = PClient smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe (THandleAuth 'TClient) -> STM SMPClient smpClientStub g sessionId thVersion thAuth = do + let ts = UTCTime (read "2024-03-31") 0 connected <- newTVar False clientCorrId <- C.newRandomDRG g sentCommands <- TM.empty - pingErrorCount <- newTVar 0 + sendPings <- newTVar False + lastReceived <- newTVar ts + timeoutErrorCount <- newTVar 0 sndQ <- newTBQueue 100 rcvQ <- newTBQueue 100 return @@ -159,7 +164,7 @@ smpClientStub g sessionId thVersion thAuth = do implySessId = thVersion >= authCmdsSMPVersion, batch = True }, - sessionTs = UTCTime (read "2024-03-31") 0, + sessionTs = ts, client_ = PClient { connected, @@ -167,7 +172,9 @@ smpClientStub g sessionId thVersion thAuth = do transportHost = "localhost", tcpTimeout = 15_000_000, rcvConcurrency = 8, - pingErrorCount, + sendPings, + lastReceived, + timeoutErrorCount, clientCorrId, sentCommands, sndQ, @@ -215,7 +222,7 @@ data NetworkConfig = NetworkConfig tcpKeepAlive :: Maybe KeepAliveOpts, -- | period for SMP ping commands (microseconds, 0 to disable) smpPingInterval :: Int64, - -- | the count of PING errors after which SMP client terminates (and will be reconnected), 0 to disable + -- | the count of timeout errors after which SMP client terminates (and will be reconnected), 0 to disable smpPingCount :: Int, logTLSErrors :: Bool } @@ -329,15 +336,17 @@ getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> T getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, serverVRange, agreeSecret} msgQ disconnected = do case chooseTransportHost networkConfig (host srv) of Right useHost -> - (atomically (mkProtocolClient useHost) >>= runClient useTransport useHost) + (getCurrentTime >>= atomically . mkProtocolClient useHost >>= runClient useTransport useHost) `catch` \(e :: IOException) -> pure . Left $ PCEIOError e Left e -> pure $ Left e where NetworkConfig {tcpConnectTimeout, tcpTimeout, rcvConcurrency, smpPingInterval} = networkConfig - mkProtocolClient :: TransportHost -> STM (PClient v err msg) - mkProtocolClient transportHost = do + mkProtocolClient :: TransportHost -> UTCTime -> STM (PClient v err msg) + mkProtocolClient transportHost ts = do connected <- newTVar False - pingErrorCount <- newTVar 0 + sendPings <- newTVar False + lastReceived <- newTVar ts + timeoutErrorCount <- newTVar 0 clientCorrId <- C.newRandomDRG g sentCommands <- TM.empty sndQ <- newTBQueue qSize @@ -348,7 +357,9 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize transportSession, transportHost, tcpTimeout, - pingErrorCount, + sendPings, + lastReceived, + timeoutErrorCount, clientCorrId, sentCommands, rcvConcurrency, @@ -386,6 +397,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize Right th@THandle {params} -> do sessionTs <- getCurrentTime let c' = ProtocolClient {action = Nothing, client_ = c, thParams = params, sessionTs} + atomically $ writeTVar (lastReceived c) sessionTs atomically $ do writeTVar (connected c) True putTMVar cVar $ Right c' @@ -396,17 +408,29 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= \(active, s) -> whenM (readTVarIO active) (void $ tPutLog h s) receive :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () - receive ProtocolClient {client_ = PClient {rcvQ}} h = forever $ tGet h >>= atomically . writeTBQueue rcvQ + receive ProtocolClient {client_ = PClient {rcvQ, lastReceived, timeoutErrorCount}} h = forever $ do + tGet h >>= atomically . writeTBQueue rcvQ + getCurrentTime >>= atomically . writeTVar lastReceived + atomically $ writeTVar timeoutErrorCount 0 ping :: ProtocolClient v err msg -> IO () - ping c@ProtocolClient {client_ = PClient {pingErrorCount}} = do - threadDelay' smpPingInterval - runExceptT (sendProtocolCommand c Nothing "" $ protocolPing @v @err @msg) >>= \case - Left PCEResponseTimeout -> do - cnt <- atomically $ stateTVar pingErrorCount $ \cnt -> (cnt + 1, cnt + 1) - when (maxCnt == 0 || cnt < maxCnt) $ ping c - _ -> ping c -- sendProtocolCommand resets pingErrorCount + ping c@ProtocolClient {client_ = PClient {sendPings, lastReceived, timeoutErrorCount}} = loop smpPingInterval where + loop :: Int64 -> IO () + loop delay = do + threadDelay' delay + diff <- diffUTCTime <$> getCurrentTime <*> readTVarIO lastReceived + let idle = diffToMicroseconds diff + remaining = smpPingInterval - idle + if remaining > 1_000_000 -- delay pings only for significant time + then loop remaining + else do + whenM (readTVarIO sendPings) $ void . runExceptT $ sendProtocolCommand c Nothing "" (protocolPing @v @err @msg) + -- sendProtocolCommand/getResponse updates counter for each command + cnt <- readTVarIO timeoutErrorCount + -- drop client when maxCnt of commands have timed out in sequence, but only after some time has passed after last received response + when (maxCnt == 0 || cnt < maxCnt || diff < recoverWindow) $ loop smpPingInterval + recoverWindow = 15 * 60 -- seconds maxCnt = smpPingCount networkConfig process :: ProtocolClient v err msg -> IO () @@ -504,15 +528,18 @@ createSMPQueue c (rKey, rpKey) dhKey auth subMode = -- -- https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#subscribe-to-queue subscribeSMPQueue :: SMPClient -> RcvPrivateAuthKey -> RecipientId -> ExceptT SMPClientError IO () -subscribeSMPQueue c rpKey rId = +subscribeSMPQueue c@ProtocolClient {client_ = PClient {sendPings}} rpKey rId = do + liftIO . atomically $ writeTVar sendPings True sendSMPCommand c (Just rpKey) rId SUB >>= \case - OK -> return () + OK -> pure () cmd@MSG {} -> liftIO $ writeSMPMessage c rId cmd r -> throwE . PCEUnexpectedResponse $ bshow r -- | Subscribe to multiple SMP queues batching commands if supported. subscribeSMPQueues :: SMPClient -> NonEmpty (RcvPrivateAuthKey, RecipientId) -> IO (NonEmpty (Either SMPClientError ())) -subscribeSMPQueues c qs = sendProtocolCommands c cs >>= mapM (processSUBResponse c) +subscribeSMPQueues c@ProtocolClient {client_ = PClient {sendPings}} qs = do + atomically $ writeTVar sendPings True + sendProtocolCommands c cs >>= mapM (processSUBResponse c) where cs = L.map (\(rpKey, rId) -> (Just rpKey, rId, Cmd SRecipient SUB)) qs @@ -720,11 +747,14 @@ sendProtocolCommand c@ProtocolClient {client_ = PClient {sndQ}, thParams = THand -- TODO switch to timeout or TimeManager that supports Int64 getResponse :: ProtocolClient v err msg -> TVar Bool -> Request err msg -> IO (Response err msg) -getResponse ProtocolClient {client_ = PClient {tcpTimeout, pingErrorCount, sentCommands}} active Request {corrId, entityId, responseVar} = do +getResponse ProtocolClient {client_ = PClient {tcpTimeout, timeoutErrorCount, sentCommands}} active Request {corrId, entityId, responseVar} = do response <- timeout tcpTimeout (atomically (takeTMVar responseVar)) >>= \case - Just r -> atomically (writeTVar pingErrorCount 0) $> r - Nothing -> atomically (writeTVar active False >> TM.delete corrId sentCommands) $> Left PCEResponseTimeout + Just r -> atomically (writeTVar timeoutErrorCount 0) $> r + Nothing -> do + atomically (writeTVar active False >> TM.delete corrId sentCommands) + atomically $ modifyTVar' timeoutErrorCount (+ 1) + pure $ Left PCEResponseTimeout pure Response {entityId, response} mkTransmission :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> ClientCommand msg -> IO (PCTransmission err msg) From 6f832733189f769e195e8b3951e28d88b8118ec4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 2 May 2024 15:14:01 +0100 Subject: [PATCH 026/107] client: increase timeout for SOCKS connection, increase timeout for direct connection (#1123) --- src/Simplex/Messaging/Transport/Client.hs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Messaging/Transport/Client.hs b/src/Simplex/Messaging/Transport/Client.hs index 08cff1d0d..a943b36d9 100644 --- a/src/Simplex/Messaging/Transport/Client.hs +++ b/src/Simplex/Messaging/Transport/Client.hs @@ -19,7 +19,7 @@ module Simplex.Messaging.Transport.Client TransportHost (..), TransportHosts (..), TransportHosts_ (..), - validateCertificateChain + validateCertificateChain, ) where @@ -52,7 +52,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll, parseString) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.KeepAlive -import Simplex.Messaging.Util (bshow, (<$?>), catchAll, tshow) +import Simplex.Messaging.Util (bshow, catchAll, tshow, (<$?>)) import System.IO.Error import System.Timeout (timeout) import Text.Read (readMaybe) @@ -143,14 +143,19 @@ runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, serverCert <- newEmptyTMVarIO let hostName = B.unpack $ strEncode host clientParams = mkTLSClientParams tlsParams caStore_ hostName port keyHash clientCredentials alpn serverCert - connectTCP = case socksProxy of - Just proxy -> connectSocksClient proxy proxyUsername $ hostAddr host - _ -> connectTCPClient hostName + (connectTCP, tlsTimeout) = case socksProxy of + -- We use a much larger timeout for connections via SOCKS proxy, to allow the circuits created + -- in the socket connection that would otherwise timeout to be used in the next connection attempt. + -- Using standard timeout results in permanent timeout for the clients using SOCKS in cases + -- when SOCKS proxy is very slow (bad network, congestion in underlying network, etc.), + -- because SOCKS proxy destroys circuits when the last session using them is closed. + Just proxy -> (connectSocksClient proxy proxyUsername (hostAddr host), tcpConnectTimeout * 10) + _ -> (connectTCPClient hostName, tcpConnectTimeout) c <- do sock <- connectTCP port mapM_ (setSocketKeepAlive sock) tcpKeepAlive `catchAll` \e -> logError ("Error setting TCP keep-alive" <> tshow e) let tCfg = clientTransportConfig cfg - tcpConnectTimeout `timeout` connectTLS (Just hostName) tCfg clientParams sock >>= \case + tlsTimeout `timeout` connectTLS (Just hostName) tCfg clientParams sock >>= \case Nothing -> do close sock logError "connection timed out" From 9e49c289b4273e69ed763b38b58bd45adb299065 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 1 May 2024 00:51:08 +0100 Subject: [PATCH 027/107] upgrade SMP/NTF servers to v7/v2 protocol versions (#996) * upgrade SMP/NTF servers to v7/v2 protocol versions * 5.6.0.0 --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- src/Simplex/Messaging/Notifications/Transport.hs | 2 +- src/Simplex/Messaging/Transport.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index 022403471..e2c287437 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -47,7 +47,7 @@ currentClientNTFVersion :: VersionNTF currentClientNTFVersion = VersionNTF 1 currentServerNTFVersion :: VersionNTF -currentServerNTFVersion = VersionNTF 1 +currentServerNTFVersion = VersionNTF 2 supportedClientNTFVRange :: VersionRangeNTF supportedClientNTFVRange = mkVersionRange initialNTFVersion currentClientNTFVersion diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 519154bb5..8dfd15813 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -153,7 +153,7 @@ currentClientSMPRelayVersion :: VersionSMP currentClientSMPRelayVersion = VersionSMP 6 currentServerSMPRelayVersion :: VersionSMP -currentServerSMPRelayVersion = VersionSMP 6 +currentServerSMPRelayVersion = VersionSMP 7 -- minimal supported protocol version is 4 -- TODO remove code that supports sending commands without batching From 60403955c05b9f8e72709fc724d5f9f68b216ea4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 1 May 2024 00:56:33 +0100 Subject: [PATCH 028/107] 5.7.0.4 --- CHANGELOG.md | 20 ++++++++++++++++++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e06ac1caa..ad8862b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 5.7.0 + +Version 5.7.0.4 + +_Please note_: the earliest SimpleX Chat clients supported by this version of the servers is 5.5.3 (released on February 11, 2024). + +SMP server: +- increase max SMP protocol version to 7 (support for deniable authenticators). + +NTF server: +- increase max NTF protocol version to 2 (support for deniable authenticators). + +XFTP server: +- version handshake using ALPN. + +SMP agent: +- increase timeouts for XFTP files. +- don't send commands after timeout. +- PQ encryption support. + # 5.6.2 Version 5.6.2.2. diff --git a/package.yaml b/package.yaml index b4c1f1d78..084fe3a8f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.0.3 +version: 5.7.0.4 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index f353925bd..1bdd67c0b 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.0.3 +version: 5.7.0.4 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From c5941b790b10e0896fb9c8c59b5123657f827003 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 2 May 2024 15:14:01 +0100 Subject: [PATCH 029/107] client: increase timeout for SOCKS connection, increase timeout for direct connection (#1123) --- src/Simplex/Messaging/Transport/Client.hs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Messaging/Transport/Client.hs b/src/Simplex/Messaging/Transport/Client.hs index 08cff1d0d..a943b36d9 100644 --- a/src/Simplex/Messaging/Transport/Client.hs +++ b/src/Simplex/Messaging/Transport/Client.hs @@ -19,7 +19,7 @@ module Simplex.Messaging.Transport.Client TransportHost (..), TransportHosts (..), TransportHosts_ (..), - validateCertificateChain + validateCertificateChain, ) where @@ -52,7 +52,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll, parseString) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.KeepAlive -import Simplex.Messaging.Util (bshow, (<$?>), catchAll, tshow) +import Simplex.Messaging.Util (bshow, catchAll, tshow, (<$?>)) import System.IO.Error import System.Timeout (timeout) import Text.Read (readMaybe) @@ -143,14 +143,19 @@ runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, serverCert <- newEmptyTMVarIO let hostName = B.unpack $ strEncode host clientParams = mkTLSClientParams tlsParams caStore_ hostName port keyHash clientCredentials alpn serverCert - connectTCP = case socksProxy of - Just proxy -> connectSocksClient proxy proxyUsername $ hostAddr host - _ -> connectTCPClient hostName + (connectTCP, tlsTimeout) = case socksProxy of + -- We use a much larger timeout for connections via SOCKS proxy, to allow the circuits created + -- in the socket connection that would otherwise timeout to be used in the next connection attempt. + -- Using standard timeout results in permanent timeout for the clients using SOCKS in cases + -- when SOCKS proxy is very slow (bad network, congestion in underlying network, etc.), + -- because SOCKS proxy destroys circuits when the last session using them is closed. + Just proxy -> (connectSocksClient proxy proxyUsername (hostAddr host), tcpConnectTimeout * 10) + _ -> (connectTCPClient hostName, tcpConnectTimeout) c <- do sock <- connectTCP port mapM_ (setSocketKeepAlive sock) tcpKeepAlive `catchAll` \e -> logError ("Error setting TCP keep-alive" <> tshow e) let tCfg = clientTransportConfig cfg - tcpConnectTimeout `timeout` connectTLS (Just hostName) tCfg clientParams sock >>= \case + tlsTimeout `timeout` connectTLS (Just hostName) tCfg clientParams sock >>= \case Nothing -> do close sock logError "connection timed out" From 8d8010a62aef2241fec3876fcfe57d51456b2bc0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 2 May 2024 16:22:55 +0100 Subject: [PATCH 030/107] 5.7.1.0 --- CHANGELOG.md | 5 +++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8862b0f..ffdaa7ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 5.7.1 + +SMP agent: +- increase timeout for TLS connection via SOCKS + # 5.7.0 Version 5.7.0.4 diff --git a/package.yaml b/package.yaml index 084fe3a8f..ccf0794ae 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.0.4 +version: 5.7.1.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 1bdd67c0b..9b5fa36b0 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.0.4 +version: 5.7.1.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From b586a6e90af5eef0e974ac9209f7ee20a7bf6d35 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 3 May 2024 22:16:52 +0100 Subject: [PATCH 031/107] client: removed concurrency limit when waiting for subscription results (#1126) --- src/Simplex/Messaging/Client.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index f54fe686f..8d3c5e54f 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -718,7 +718,7 @@ sendBatch c@ProtocolClient {client_ = PClient {rcvConcurrency, sndQ}} b = do | n > 0 -> do active <- newTVarIO True atomically $ writeTBQueue sndQ (active, s) - pooledMapConcurrentlyN rcvConcurrency (getResponse c active) rs + mapConcurrently (getResponse c active) rs | otherwise -> pure [] TBTransmission s r -> do active <- newTVarIO True From 0e205e70adbe269d43914ef62f04bbbfd04fbeec Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Sat, 4 May 2024 01:39:00 +0300 Subject: [PATCH 032/107] add TRcvQueues tests (#1117) Co-authored-by: Evgeny Poberezkin --- tests/CoreTests/TRcvQueuesTests.hs | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/CoreTests/TRcvQueuesTests.hs b/tests/CoreTests/TRcvQueuesTests.hs index 91722228b..2b0009344 100644 --- a/tests/CoreTests/TRcvQueuesTests.hs +++ b/tests/CoreTests/TRcvQueuesTests.hs @@ -22,10 +22,13 @@ tRcvQueuesTests = do describe "connection API" $ do it "hasConn" hasConnTest it "hasConn, batch add" hasConnTestBatch + it "hasConn, batch idempotent" batchIdempotentTest it "deleteConn" deleteConnTest describe "session API" $ do it "getSessQueues" getSessQueuesTest it "getDelSessQueues" getDelSessQueuesTest + describe "queue transfer" $ do + it "getDelSessQueues-batchAddQueues preserves total length" removeSubsTest checkDataInvariant :: RQ.TRcvQueues -> IO Bool checkDataInvariant trq = atomically $ do @@ -62,6 +65,19 @@ hasConnTestBatch = do atomically (RQ.hasConn "c3" trq) `shouldReturn` True atomically (RQ.hasConn "nope" trq) `shouldReturn` False +batchIdempotentTest :: IO () +batchIdempotentTest = do + trq <- atomically RQ.empty + let qs = [dummyRQ 0 "smp://1234-w==@alpha" "c1", dummyRQ 0 "smp://1234-w==@alpha" "c2", dummyRQ 0 "smp://1234-w==@beta" "c3"] + atomically $ RQ.batchAddQueues trq qs + checkDataInvariant trq `shouldReturn` True + qs' <- readTVarIO $ RQ.getRcvQueues trq + cs' <- readTVarIO $ RQ.getConnections trq + atomically $ RQ.batchAddQueues trq qs + checkDataInvariant trq `shouldReturn` True + readTVarIO (RQ.getRcvQueues trq) `shouldReturn` qs' + fmap L.nub <$> readTVarIO (RQ.getConnections trq) `shouldReturn`cs' -- connections get duplicated, but that doesn't appear to affect anybody + deleteConnTest :: IO () deleteConnTest = do trq <- atomically RQ.empty @@ -121,6 +137,40 @@ getDelSessQueuesTest = do atomically (RQ.hasConn "c3" trq) `shouldReturn` True atomically (RQ.hasConn "c4" trq) `shouldReturn` True +removeSubsTest :: IO () +removeSubsTest = do + aq <- atomically RQ.empty + let qs = + [ dummyRQ 0 "smp://1234-w==@alpha" "c1", + dummyRQ 0 "smp://1234-w==@alpha" "c2", + dummyRQ 0 "smp://1234-w==@beta" "c3", + dummyRQ 1 "smp://1234-w==@beta" "c4" + ] + atomically $ RQ.batchAddQueues aq qs + + pq <- atomically RQ.empty + atomically (totalSize aq pq) `shouldReturn` (4, 4) + + atomically $ RQ.getDelSessQueues (0, "smp://1234-w==@alpha", Nothing) aq >>= RQ.batchAddQueues pq . fst + atomically (totalSize aq pq) `shouldReturn` (4, 4) + + atomically $ RQ.getDelSessQueues (0, "smp://1234-w==@beta", Just "non-existent") aq >>= RQ.batchAddQueues pq . fst + atomically (totalSize aq pq) `shouldReturn` (4, 4) + + atomically $ RQ.getDelSessQueues (0, "smp://1234-w==@localhost", Nothing) aq >>= RQ.batchAddQueues pq . fst + atomically (totalSize aq pq) `shouldReturn` (4, 4) + + atomically $ RQ.getDelSessQueues (0, "smp://1234-w==@beta", Just "c3") aq >>= RQ.batchAddQueues pq . fst + atomically (totalSize aq pq) `shouldReturn` (4, 4) + +totalSize :: RQ.TRcvQueues -> RQ.TRcvQueues -> STM (Int, Int) +totalSize a b = do + qsizeA <- M.size <$> readTVar (RQ.getRcvQueues a) + qsizeB <- M.size <$> readTVar (RQ.getRcvQueues b) + csizeA <- M.size <$> readTVar (RQ.getConnections a) + csizeB <- M.size <$> readTVar (RQ.getConnections b) + pure (qsizeA + qsizeB, csizeA + csizeB) + dummyRQ :: UserId -> SMPServer -> ConnId -> RcvQueue dummyRQ userId server connId = RcvQueue From ee8e4067b02c520a41b82ab972e262b24f58cd69 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 5 May 2024 12:12:19 +0100 Subject: [PATCH 033/107] agent: prepare connection record before joining to prevent race conditions (#1128) * agent: prepare connection record before joining to prevent race conditions * prepare connection for contact address as well * clean up --- src/Simplex/Messaging/Agent.hs | 100 +++++++++++++++++-------- src/Simplex/Messaging/Client.hs | 1 - tests/AgentTests/FunctionalAPITests.hs | 12 ++- 3 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 38112b030..f93d03e35 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -55,6 +55,7 @@ module Simplex.Messaging.Agent deleteConnectionAsync, deleteConnectionsAsync, createConnection, + prepareConnectionToJoin, joinConnection, allowConnection, acceptContact, @@ -149,7 +150,7 @@ import Simplex.FileTransfer.Protocol (FileParty (..)) import Simplex.FileTransfer.Util (removePath) import Simplex.Messaging.Agent.Client import Simplex.Messaging.Agent.Env.SQLite -import Simplex.Messaging.Agent.Lock (withLock', withLock) +import Simplex.Messaging.Agent.Lock (withLock, withLock') import Simplex.Messaging.Agent.NtfSubSupervisor import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.RetryInterval @@ -160,7 +161,7 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client (ProtocolClient (..), ServerTransmission) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile, CryptoFileArgs) -import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..), pattern PQEncOn, pattern PQEncOff, pattern PQSupportOn, pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -198,7 +199,7 @@ getSMPAgentClient_ clientId cfg initServers store backgroundMode = liftIO $ newSMPAgentEnv cfg store >>= runReaderT runAgent where runAgent = do - c@AgentClient {acThread} <- atomically . newAgentClient clientId initServers =<< ask + c@AgentClient {acThread} <- atomically . newAgentClient clientId initServers =<< ask t <- runAgentThreads c `forkFinally` const (liftIO $ disconnectAgentClient c) atomically . writeTVar acThread . Just =<< mkWeakThreadId t pure c @@ -239,7 +240,7 @@ createUser c = withAgentEnv c .: createUser' c {-# INLINE createUser #-} -- | Delete user record optionally deleting all user's connections on SMP servers -deleteUser :: AgentClient -> UserId -> Bool -> AE () +deleteUser :: AgentClient -> UserId -> Bool -> AE () deleteUser c = withAgentEnv c .: deleteUser' c {-# INLINE deleteUser #-} @@ -288,9 +289,18 @@ createConnection :: AgentClient -> UserId -> Bool -> SConnectionMode c -> Maybe createConnection c userId enableNtfs = withAgentEnv c .:: newConn c userId "" enableNtfs {-# INLINE createConnection #-} --- | Join SMP agent connection (JOIN command) -joinConnection :: AgentClient -> UserId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> AE ConnId -joinConnection c userId enableNtfs = withAgentEnv c .:: joinConn c userId "" enableNtfs +-- | Create SMP agent connection without queue (to be joined with joinConnection passing connection ID). +-- This method is required to prevent race condition when confirmation from peer is received before +-- the caller of joinConnection saves connection ID to the database. +-- Instead of it we could send confirmation asynchronously, but then it would be harder to report +-- "link deleted" (SMP AUTH) interactively, so this approach is simpler overall. +prepareConnectionToJoin :: AgentClient -> UserId -> Bool -> ConnectionRequestUri c -> PQSupport -> AE ConnId +prepareConnectionToJoin c userId enableNtfs = withAgentEnv c .: newConnToJoin c userId "" enableNtfs + +-- | Join SMP agent connection (JOIN command). +joinConnection :: AgentClient -> UserId -> Maybe ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> AE ConnId +joinConnection c userId Nothing enableNtfs = withAgentEnv c .:: joinConn c userId "" False enableNtfs +joinConnection c userId (Just connId) enableNtfs = withAgentEnv c .:: joinConn c userId connId True enableNtfs {-# INLINE joinConnection #-} -- | Allow connection to continue after CONF notification (LET command) @@ -575,7 +585,7 @@ processCommand :: AgentClient -> (EntityId, APartyCmd 'Client) -> AM (EntityId, processCommand c (connId, APC e cmd) = second (APC e) <$> case cmd of NEW enableNtfs (ACM cMode) pqIK subMode -> second (INV . ACR cMode) <$> newConn c userId connId enableNtfs cMode Nothing pqIK subMode - JOIN enableNtfs (ACR _ cReq) pqEnc subMode connInfo -> (,OK) <$> joinConn c userId connId enableNtfs cReq connInfo pqEnc subMode + JOIN enableNtfs (ACR _ cReq) pqEnc subMode connInfo -> (,OK) <$> joinConn c userId connId False enableNtfs cReq connInfo pqEnc subMode LET confId ownCInfo -> allowConnection' c connId confId ownCInfo $> (connId, OK) ACPT invId pqEnc ownCInfo -> (,OK) <$> acceptContact' c connId True invId ownCInfo pqEnc SMSubscribe RJCT invId -> rejectContact' c connId invId $> (connId, OK) @@ -708,11 +718,14 @@ switchConnectionAsync' c corrId connId = newConn :: AgentClient -> UserId -> ConnId -> Bool -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys -> SubscriptionMode -> AM (ConnId, ConnectionRequestUri c) newConn c userId connId enableNtfs cMode clientData pqInitKeys subMode = - getSMPServer c userId >>= newConnSrv c userId connId enableNtfs cMode clientData pqInitKeys subMode + getSMPServer c userId >>= newConnSrv c userId connId False enableNtfs cMode clientData pqInitKeys subMode -newConnSrv :: AgentClient -> UserId -> ConnId -> Bool -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys -> SubscriptionMode -> SMPServerWithAuth -> AM (ConnId, ConnectionRequestUri c) -newConnSrv c userId connId enableNtfs cMode clientData pqInitKeys subMode srv = do - connId' <- newConnNoQueues c userId connId enableNtfs cMode (CR.connPQEncryption pqInitKeys) +newConnSrv :: AgentClient -> UserId -> ConnId -> Bool -> Bool -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys -> SubscriptionMode -> SMPServerWithAuth -> AM (ConnId, ConnectionRequestUri c) +newConnSrv c userId connId hasNewConn enableNtfs cMode clientData pqInitKeys subMode srv = do + connId' <- + if hasNewConn + then pure connId + else newConnNoQueues c userId connId enableNtfs cMode (CR.connPQEncryption pqInitKeys) newRcvConnSrv c userId connId' enableNtfs cMode clientData pqInitKeys subMode srv newRcvConnSrv :: AgentClient -> UserId -> ConnId -> Bool -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys -> SubscriptionMode -> SMPServerWithAuth -> AM (ConnId, ConnectionRequestUri c) @@ -738,18 +751,36 @@ newRcvConnSrv c userId connId enableNtfs cMode clientData pqInitKeys subMode srv withStore' c $ \db -> createRatchetX3dhKeys db connId pk1 pk2 pKem pure (connId, CRInvitationUri crData $ toVersionRangeT e2eRcvParams e2eEncryptVRange) -joinConn :: AgentClient -> UserId -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> AM ConnId -joinConn c userId connId enableNtfs cReq cInfo pqSupport subMode = do +newConnToJoin :: forall c. AgentClient -> UserId -> ConnId -> Bool -> ConnectionRequestUri c -> PQSupport -> AM ConnId +newConnToJoin c userId connId enableNtfs cReq pqSup = case cReq of + CRInvitationUri {} -> + lift (compatibleInvitationUri cReq) >>= \case + Just (_, (Compatible (CR.E2ERatchetParams v _ _ _)), aVersion) -> create aVersion (Just v) + Nothing -> throwError $ AGENT A_VERSION + CRContactUri {} -> + lift (compatibleContactUri cReq) >>= \case + Just (_, aVersion) -> create aVersion Nothing + Nothing -> throwError $ AGENT A_VERSION + where + create :: Compatible VersionSMPA -> Maybe CR.VersionE2E -> AM ConnId + create (Compatible connAgentVersion) e2eV_ = do + g <- asks random + let pqSupport = pqSup `CR.pqSupportAnd` versionPQSupport_ connAgentVersion e2eV_ + cData = ConnData {userId, connId, connAgentVersion, enableNtfs, lastExternalSndId = 0, deleted = False, ratchetSyncState = RSOk, pqSupport} + withStore c $ \db -> createNewConn db g cData SCMInvitation + +joinConn :: AgentClient -> UserId -> ConnId -> Bool -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> AM ConnId +joinConn c userId connId hasNewConn enableNtfs cReq cInfo pqSupport subMode = do srv <- case cReq of CRInvitationUri ConnReqUriData {crSmpQueues = q :| _} _ -> getNextServer c userId [qServer q] _ -> getSMPServer c userId - joinConnSrv c userId connId enableNtfs cReq cInfo pqSupport subMode srv + joinConnSrv c userId connId hasNewConn enableNtfs cReq cInfo pqSupport subMode srv -startJoinInvitation :: UserId -> ConnId -> Bool -> ConnectionRequestUri 'CMInvitation -> PQSupport -> AM (Compatible VersionSMPA, ConnData, NewSndQueue, CR.Ratchet 'C.X448, CR.SndE2ERatchetParams 'C.X448) +startJoinInvitation :: UserId -> ConnId -> Bool -> ConnectionRequestUri 'CMInvitation -> PQSupport -> AM (ConnData, NewSndQueue, CR.Ratchet 'C.X448, CR.SndE2ERatchetParams 'C.X448) startJoinInvitation userId connId enableNtfs cReqUri pqSup = lift (compatibleInvitationUri cReqUri) >>= \case - Just (qInfo, (Compatible e2eRcvParams@(CR.E2ERatchetParams v _ rcDHRr kem_)), aVersion@(Compatible connAgentVersion)) -> do + Just (qInfo, (Compatible e2eRcvParams@(CR.E2ERatchetParams v _ rcDHRr kem_)), Compatible connAgentVersion) -> do g <- asks random let pqSupport = pqSup `CR.pqSupportAnd` versionPQSupport_ connAgentVersion (Just v) (pk1, pk2, pKem, e2eSndParams) <- liftIO $ CR.generateSndE2EParams g v (CR.replyKEM_ v kem_ pqSupport) @@ -760,7 +791,7 @@ startJoinInvitation userId connId enableNtfs cReqUri pqSup = rc = CR.initSndRatchet rcVs rcDHRr rcDHRs rcParams q <- lift $ newSndQueue userId "" qInfo let cData = ConnData {userId, connId, connAgentVersion, enableNtfs, lastExternalSndId = 0, deleted = False, ratchetSyncState = RSOk, pqSupport} - pure (aVersion, cData, q, rc, e2eSndParams) + pure (cData, q, rc, e2eSndParams) Nothing -> throwError $ AGENT A_VERSION connRequestPQSupport :: AgentClient -> PQSupport -> ConnectionRequestUri c -> IO (Maybe (VersionSMPA, PQSupport)) @@ -786,40 +817,43 @@ compatibleContactUri (CRContactUri ConnReqUriData {crAgentVRange, crSmpQueues = AgentConfig {smpClientVRange, smpAgentVRange} <- asks config pure $ (,) - <$> (qUri `compatibleVersion` smpClientVRange) + <$> (qUri `compatibleVersion` smpClientVRange) <*> (crAgentVRange `compatibleVersion` smpAgentVRange) versionPQSupport_ :: VersionSMPA -> Maybe CR.VersionE2E -> PQSupport versionPQSupport_ agentV e2eV_ = PQSupport $ agentV >= pqdrSMPAgentVersion && maybe True (>= CR.pqRatchetE2EEncryptVersion) e2eV_ {-# INLINE versionPQSupport_ #-} -joinConnSrv :: AgentClient -> UserId -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> SMPServerWithAuth -> AM ConnId -joinConnSrv c userId connId enableNtfs inv@CRInvitationUri {} cInfo pqSup subMode srv = +joinConnSrv :: AgentClient -> UserId -> ConnId -> Bool -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> SMPServerWithAuth -> AM ConnId +joinConnSrv c userId connId hasNewConn enableNtfs inv@CRInvitationUri {} cInfo pqSup subMode srv = withInvLock c (strEncode inv) "joinConnSrv" $ do - (aVersion, cData, q, rc, e2eSndParams) <- startJoinInvitation userId connId enableNtfs inv pqSup + (cData, q, rc, e2eSndParams) <- startJoinInvitation userId connId enableNtfs inv pqSup g <- asks random (connId', sq) <- withStore c $ \db -> runExceptT $ do - r@(connId', _) <- ExceptT $ createSndConn db g cData q + r@(connId', _) <- + if hasNewConn + then (connId,) <$> ExceptT (updateNewConnSnd db connId q) + else ExceptT $ createSndConn db g cData q liftIO $ createRatchet db connId' rc pure r let cData' = (cData :: ConnData) {connId = connId'} - tryError (confirmQueue aVersion c cData' sq srv cInfo (Just e2eSndParams) subMode) >>= \case + tryError (confirmQueue c cData' sq srv cInfo (Just e2eSndParams) subMode) >>= \case Right _ -> pure connId' Left e -> do -- possible improvement: recovery for failure on network timeout, see rfcs/2022-04-20-smp-conf-timeout-recovery.md void $ withStore' c $ \db -> deleteConn db Nothing connId' throwError e -joinConnSrv c userId connId enableNtfs cReqUri@CRContactUri {} cInfo pqSup subMode srv = +joinConnSrv c userId connId hasNewConn enableNtfs cReqUri@CRContactUri {} cInfo pqSup subMode srv = lift (compatibleContactUri cReqUri) >>= \case Just (qInfo, vrsn) -> do - (connId', cReq) <- newConnSrv c userId connId enableNtfs SCMInvitation Nothing (CR.IKNoPQ pqSup) subMode srv + (connId', cReq) <- newConnSrv c userId connId hasNewConn enableNtfs SCMInvitation Nothing (CR.IKNoPQ pqSup) subMode srv sendInvitation c userId qInfo vrsn cReq cInfo pure connId' Nothing -> throwError $ AGENT A_VERSION joinConnSrvAsync :: AgentClient -> UserId -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> PQSupport -> SubscriptionMode -> SMPServerWithAuth -> AM () joinConnSrvAsync c userId connId enableNtfs inv@CRInvitationUri {} cInfo pqSupport subMode srv = do - (_aVersion, cData, q, rc, e2eSndParams) <- startJoinInvitation userId connId enableNtfs inv pqSupport + (cData, q, rc, e2eSndParams) <- startJoinInvitation userId connId enableNtfs inv pqSupport q' <- withStore c $ \db -> runExceptT $ do liftIO $ createRatchet db connId rc ExceptT $ updateNewConnSnd db connId q @@ -861,7 +895,7 @@ acceptContact' c connId enableNtfs invId ownConnInfo pqSupport subMode = withCon withStore c (`getConn` contactConnId) >>= \case SomeConn _ (ContactConnection ConnData {userId} _) -> do withStore' c $ \db -> acceptInvitation db invId ownConnInfo - joinConn c userId connId enableNtfs connReq ownConnInfo pqSupport subMode `catchAgentError` \err -> do + joinConn c userId connId False enableNtfs connReq ownConnInfo pqSupport subMode `catchAgentError` \err -> do withStore' c (`unacceptInvitation` invId) throwError err _ -> throwError $ CMD PROHIBITED @@ -1207,7 +1241,7 @@ enqueueMessage c cData sq msgFlags aMessage = {-# INLINE enqueueMessage #-} -- this function is used only for sending messages in batch, it returns the list of successes to enqueue additional deliveries -enqueueMessageB :: forall t. (Traversable t) => AgentClient -> t (Either AgentErrorType (ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage)) -> AM' (t (Either AgentErrorType ((AgentMsgId, PQEncryption), Maybe (ConnData, [SndQueue], AgentMsgId)))) +enqueueMessageB :: forall t. Traversable t => AgentClient -> t (Either AgentErrorType (ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage)) -> AM' (t (Either AgentErrorType ((AgentMsgId, PQEncryption), Maybe (ConnData, [SndQueue], AgentMsgId)))) enqueueMessageB c reqs = do cfg <- asks config reqMids <- withStoreBatch c $ \db -> fmap (bindRight $ storeSentMsg db cfg) reqs @@ -1239,7 +1273,7 @@ enqueueSavedMessage :: AgentClient -> ConnData -> AgentMsgId -> SndQueue -> AM' enqueueSavedMessage c cData msgId sq = enqueueSavedMessageB c $ Identity (cData, [sq], msgId) {-# INLINE enqueueSavedMessage #-} -enqueueSavedMessageB :: (Foldable t) => AgentClient -> t (ConnData, [SndQueue], AgentMsgId) -> AM' () +enqueueSavedMessageB :: Foldable t => AgentClient -> t (ConnData, [SndQueue], AgentMsgId) -> AM' () enqueueSavedMessageB c reqs = do -- saving to the database is in the start to avoid race conditions when delivery is read from queue before it is saved void $ withStoreBatch' c $ \db -> concatMap (storeDeliveries db) reqs @@ -2565,8 +2599,8 @@ confirmQueueAsync c cData sq srv connInfo e2eEncryption_ subMode = do storeConfirmation c cData sq e2eEncryption_ =<< mkAgentConfirmation c cData sq srv connInfo subMode lift $ submitPendingMsg c cData sq -confirmQueue :: Compatible VersionSMPA -> AgentClient -> ConnData -> SndQueue -> SMPServerWithAuth -> ConnInfo -> Maybe (CR.SndE2ERatchetParams 'C.X448) -> SubscriptionMode -> AM () -confirmQueue (Compatible agentVersion) c cData@ConnData {connId, pqSupport} sq srv connInfo e2eEncryption_ subMode = do +confirmQueue :: AgentClient -> ConnData -> SndQueue -> SMPServerWithAuth -> ConnInfo -> Maybe (CR.SndE2ERatchetParams 'C.X448) -> SubscriptionMode -> AM () +confirmQueue c cData@ConnData {connId, connAgentVersion, pqSupport} sq srv connInfo e2eEncryption_ subMode = do msg <- mkConfirmation =<< mkAgentConfirmation c cData sq srv connInfo subMode sendConfirmation c sq msg withStore' c $ \db -> setSndQueueStatus db sq Confirmed @@ -2578,7 +2612,7 @@ confirmQueue (Compatible agentVersion) c cData@ConnData {connId, pqSupport} sq s void . liftIO $ updateSndIds db connId let pqEnc = CR.pqSupportToEnc pqSupport (encConnInfo, _) <- agentRatchetEncrypt db cData (smpEncode aMessage) e2eEncConnInfoLength (Just pqEnc) currentE2EVersion - pure . smpEncode $ AgentConfirmation {agentVersion, e2eEncryption_, encConnInfo} + pure . smpEncode $ AgentConfirmation {agentVersion = connAgentVersion, e2eEncryption_, encConnInfo} mkAgentConfirmation :: AgentClient -> ConnData -> SndQueue -> SMPServerWithAuth -> ConnInfo -> SubscriptionMode -> AM AgentMessage mkAgentConfirmation c cData sq srv connInfo subMode = do diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 8d3c5e54f..38c36f9f2 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -113,7 +113,6 @@ import Simplex.Messaging.Transport.WebSockets (WS) import Simplex.Messaging.Util (bshow, diffToMicroseconds, raceAny_, threadDelay', whenM) import Simplex.Messaging.Version import System.Timeout (timeout) -import UnliftIO (pooledMapConcurrentlyN) -- | 'SMPClient' is a handle used to send commands to a specific SMP server. -- diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 79742efab..d3e4e6924 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -244,7 +244,7 @@ createConnection :: AgentClient -> UserId -> Bool -> SConnectionMode c -> Maybe createConnection c userId enableNtfs cMode clientData = A.createConnection c userId enableNtfs cMode clientData (IKNoPQ PQSupportOn) joinConnection :: AgentClient -> UserId -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> AE ConnId -joinConnection c userId enableNtfs cReq connInfo = A.joinConnection c userId enableNtfs cReq connInfo PQSupportOn +joinConnection c userId enableNtfs cReq connInfo = A.joinConnection c userId Nothing enableNtfs cReq connInfo PQSupportOn sendMessage :: AgentClient -> ConnId -> SMP.MsgFlags -> MsgBody -> AE AgentMsgId sendMessage c connId msgFlags msgBody = do @@ -503,7 +503,7 @@ runAgentClientTest :: HasCallStack => PQSupport -> AgentClient -> AgentClient -> runAgentClientTest pqSupport alice@AgentClient {} bob baseId = runRight_ $ do (bobId, qInfo) <- A.createConnection alice 1 True SCMInvitation Nothing (IKNoPQ pqSupport) SMSubscribe - aliceId <- A.joinConnection bob 1 True qInfo "bob's connInfo" pqSupport SMSubscribe + aliceId <- A.joinConnection bob 1 Nothing True qInfo "bob's connInfo" pqSupport SMSubscribe ("", _, A.CONF confId pqSup' _ "bob's connInfo") <- get alice liftIO $ pqSup' `shouldBe` pqSupport allowConnection alice bobId confId "alice's connInfo" @@ -630,7 +630,9 @@ runAgentClientContactTest :: HasCallStack => PQSupport -> AgentClient -> AgentCl runAgentClientContactTest pqSupport alice bob baseId = runRight_ $ do (_, qInfo) <- A.createConnection alice 1 True SCMContact Nothing (IKNoPQ pqSupport) SMSubscribe - aliceId <- A.joinConnection bob 1 True qInfo "bob's connInfo" pqSupport SMSubscribe + aliceId <- A.prepareConnectionToJoin bob 1 True qInfo pqSupport + aliceId' <- A.joinConnection bob 1 (Just aliceId) True qInfo "bob's connInfo" pqSupport SMSubscribe + liftIO $ aliceId' `shouldBe` aliceId ("", _, A.REQ invId pqSup' _ "bob's connInfo") <- get alice liftIO $ pqSup' `shouldBe` pqSupport bobId <- acceptContact alice True invId "alice's connInfo" PQSupportOn SMSubscribe @@ -1399,7 +1401,9 @@ makeConnectionForUsers = makeConnectionForUsers_ PQSupportOn makeConnectionForUsers_ :: PQSupport -> AgentClient -> UserId -> AgentClient -> UserId -> ExceptT AgentErrorType IO (ConnId, ConnId) makeConnectionForUsers_ pqSupport alice aliceUserId bob bobUserId = do (bobId, qInfo) <- A.createConnection alice aliceUserId True SCMInvitation Nothing (CR.IKNoPQ pqSupport) SMSubscribe - aliceId <- A.joinConnection bob bobUserId True qInfo "bob's connInfo" pqSupport SMSubscribe + aliceId <- A.prepareConnectionToJoin bob bobUserId True qInfo pqSupport + aliceId' <- A.joinConnection bob bobUserId (Just aliceId) True qInfo "bob's connInfo" pqSupport SMSubscribe + liftIO $ aliceId' `shouldBe` aliceId ("", _, A.CONF confId pqSup' _ "bob's connInfo") <- get alice liftIO $ pqSup' `shouldBe` pqSupport allowConnection alice bobId confId "alice's connInfo" From e13b0df539cd259187277ad1fb790fae8d505574 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 5 May 2024 17:05:51 +0100 Subject: [PATCH 034/107] client: remove TLS handshake timeout (#1129) * client: remove TLS handshake timeout * remove comment --- src/Simplex/Messaging/Transport/Client.hs | 36 ++++++++--------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/Simplex/Messaging/Transport/Client.hs b/src/Simplex/Messaging/Transport/Client.hs index a943b36d9..da2c6c253 100644 --- a/src/Simplex/Messaging/Transport/Client.hs +++ b/src/Simplex/Messaging/Transport/Client.hs @@ -54,7 +54,6 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.KeepAlive import Simplex.Messaging.Util (bshow, catchAll, tshow, (<$?>)) import System.IO.Error -import System.Timeout (timeout) import Text.Read (readMaybe) import UnliftIO.Exception (IOException) import qualified UnliftIO.Exception as E @@ -139,35 +138,26 @@ runTransportClient :: Transport c => TransportClientConfig -> Maybe ByteString - runTransportClient = runTLSTransportClient supportedParameters Nothing runTLSTransportClient :: Transport c => T.Supported -> Maybe XS.CertificateStore -> TransportClientConfig -> Maybe ByteString -> TransportHost -> ServiceName -> Maybe C.KeyHash -> (c -> IO a) -> IO a -runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, tcpConnectTimeout, tcpKeepAlive, clientCredentials, alpn} proxyUsername host port keyHash client = do +runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, tcpKeepAlive, clientCredentials, alpn} proxyUsername host port keyHash client = do serverCert <- newEmptyTMVarIO let hostName = B.unpack $ strEncode host clientParams = mkTLSClientParams tlsParams caStore_ hostName port keyHash clientCredentials alpn serverCert - (connectTCP, tlsTimeout) = case socksProxy of - -- We use a much larger timeout for connections via SOCKS proxy, to allow the circuits created - -- in the socket connection that would otherwise timeout to be used in the next connection attempt. - -- Using standard timeout results in permanent timeout for the clients using SOCKS in cases - -- when SOCKS proxy is very slow (bad network, congestion in underlying network, etc.), - -- because SOCKS proxy destroys circuits when the last session using them is closed. - Just proxy -> (connectSocksClient proxy proxyUsername (hostAddr host), tcpConnectTimeout * 10) - _ -> (connectTCPClient hostName, tcpConnectTimeout) + connectTCP = case socksProxy of + Just proxy -> connectSocksClient proxy proxyUsername (hostAddr host) + _ -> connectTCPClient hostName c <- do sock <- connectTCP port mapM_ (setSocketKeepAlive sock) tcpKeepAlive `catchAll` \e -> logError ("Error setting TCP keep-alive" <> tshow e) let tCfg = clientTransportConfig cfg - tlsTimeout `timeout` connectTLS (Just hostName) tCfg clientParams sock >>= \case - Nothing -> do - close sock - logError "connection timed out" - fail "connection timed out" - Just tls -> do - chain <- - atomically (tryTakeTMVar serverCert) >>= \case - Nothing -> do - logError "onServerCertificate didn't fire or failed to get cert chain" - closeTLS tls >> error "onServerCertificate failed" - Just c -> pure c - getClientConnection tCfg chain tls + -- No TLS timeout to avoid failing connections via SOCKS + tls <- connectTLS (Just hostName) tCfg clientParams sock + chain <- + atomically (tryTakeTMVar serverCert) >>= \case + Nothing -> do + logError "onServerCertificate didn't fire or failed to get cert chain" + closeTLS tls >> error "onServerCertificate failed" + Just c -> pure c + getClientConnection tCfg chain tls client c `E.finally` closeConnection c where hostAddr = \case From 93fd424f86086c6f378b50e343f32ec47f8b0c3f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 5 May 2024 17:13:26 +0100 Subject: [PATCH 035/107] 5.7.2.0 --- CHANGELOG.md | 7 +++++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffdaa7ff2..b5792195d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 5.7.2 + +SMP agent: +- fix connections failing when connecting via link due to race condition on slow network. +- remove concurrency limit when waiting for connection subscription. +- remove TLS timeout. + # 5.7.1 SMP agent: diff --git a/package.yaml b/package.yaml index ccf0794ae..7a536f7bf 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.1.0 +version: 5.7.2.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 9b5fa36b0..7539d675a 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.1.0 +version: 5.7.2.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From c85f6a2f0e6c12272f314e8c4cdf0b313deda4ea Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 7 May 2024 00:00:42 +0300 Subject: [PATCH 036/107] proxy: reporting errors (#1108) * smp-proxy: iron out errors * treat proxy timeouts as temporary * update errors * proxy errors (missing encoding) * update * enable tests * update * update * fix * fix * simplify * test --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Agent.hs | 4 +- src/Simplex/Messaging/Agent/Client.hs | 25 ++++- src/Simplex/Messaging/Agent/Protocol.hs | 28 ++--- src/Simplex/Messaging/Client.hs | 121 +++++++++++++++------- src/Simplex/Messaging/Protocol.hs | 129 ++++++++++++++++-------- src/Simplex/Messaging/Server.hs | 59 +++++------ tests/CoreTests/ProtocolErrorTests.hs | 45 ++++++--- tests/SMPProxyTests.hs | 8 +- 8 files changed, 266 insertions(+), 153 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 3b68667e4..3a0e13157 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -2250,9 +2250,9 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, | otherwise -> ignored _ -> ignored ignored = pure "END from disconnected client - ignored" - _ -> do + r -> do logServer "<--" c srv rId $ "unexpected: " <> bshow cmd - notify . ERR $ BROKER (B.unpack $ strEncode srv) $ if isResponse then TIMEOUT else UNEXPECTED + notify . ERR $ BROKER (B.unpack $ strEncode srv) $ if isResponse then TIMEOUT else UNEXPECTED $ take 32 $ show r where notify :: forall e m. MonadIO m => AEntityI e => ACommand 'Agent e -> m () notify = atomically . notify' diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index c29e35499..35015af5a 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -1032,8 +1032,15 @@ sendOrProxySMPMessage c userId destSrv cmdStr spKey_ senderId msgFlags msg = do unknownServer = maybe True (all ((destSrv /=) . protoServer)) <$> TM.lookup userId (userServers c) sendViaProxy destSess = withProxySession c destSess senderId ("PFWD " <> cmdStr) $ \(SMPConnectedClient smp _, proxySess) -> do - liftClient SMP (clientServer smp) $ proxySMPMessage smp proxySess spKey_ senderId msgFlags msg - pure . Just $ protocolClientServer' smp + liftClient SMP (clientServer smp) (proxySMPMessage smp proxySess spKey_ senderId msgFlags msg) >>= \case + Right () -> pure . Just $ protocolClientServer' smp + Left proxyErr -> + throwError + PROXY + { proxyServer = protocolClientServer smp, + relayServer = B.unpack $ strEncode destSrv, + proxyErr + } sendDirectly tSess = withLogClient_ c tSess senderId ("SEND " <> cmdStr) $ \(SMPConnectedClient smp _) -> liftClient SMP (clientServer smp) $ sendSMPMessage smp spKey_ senderId msgFlags msg @@ -1066,7 +1073,7 @@ protocolClientError :: (Show err, Encoding err) => (err -> AgentErrorType) -> Ho protocolClientError protocolError_ host = \case PCEProtocolError e -> protocolError_ e PCEResponseError e -> BROKER host $ RESPONSE $ B.unpack $ smpEncode e - PCEUnexpectedResponse _ -> BROKER host UNEXPECTED + PCEUnexpectedResponse r -> BROKER host $ UNEXPECTED $ take 32 $ show r PCEResponseTimeout -> BROKER host TIMEOUT PCENetworkError -> BROKER host NETWORK PCEIncompatibleHost -> BROKER host HOST @@ -1263,15 +1270,23 @@ processSubResult c rq r = do temporaryAgentError :: AgentErrorType -> Bool temporaryAgentError = \case - BROKER _ NETWORK -> True - BROKER _ TIMEOUT -> True + BROKER _ e -> tempBrokerError e + SMP (SMP.PROXY (SMP.BROKER e)) -> tempBrokerError e + PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER e))) -> tempBrokerError e INACTIVE -> True _ -> False + where + tempBrokerError = \case + NETWORK -> True + TIMEOUT -> True + _ -> False {-# INLINE temporaryAgentError #-} temporaryOrHostError :: AgentErrorType -> Bool temporaryOrHostError = \case BROKER _ HOST -> True + SMP (SMP.PROXY (SMP.BROKER HOST)) -> True + PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER HOST))) -> True e -> temporaryAgentError e {-# INLINE temporaryOrHostError #-} diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 895a8ddc1..61ab2400b 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -189,6 +189,7 @@ import Simplex.FileTransfer.Description import Simplex.FileTransfer.Protocol (FileParty (..)) import Simplex.FileTransfer.Transport (XFTPErrorType) import Simplex.Messaging.Agent.QueryString +import Simplex.Messaging.Client (ProxyClientError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet ( InitialKeys (..), @@ -206,6 +207,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol ( AProtocolType, + BrokerErrorType (..), EntityId, ErrorType, MsgBody, @@ -233,7 +235,7 @@ import Simplex.Messaging.Protocol ) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme -import Simplex.Messaging.Transport (Transport (..), TransportError, serializeTransportError, transportErrorP) +import Simplex.Messaging.Transport (Transport (..), serializeTransportError, transportErrorP) import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts_ (..)) import Simplex.Messaging.Util import Simplex.Messaging.Version @@ -1474,6 +1476,8 @@ data AgentErrorType NTF {ntfErr :: ErrorType} | -- | XFTP protocol errors forwarded to agent clients XFTP {xftpErr :: XFTPErrorType} + | -- | SMP proxy errors + PROXY {proxyServer :: String, relayServer :: String, proxyErr :: ProxyClientError} | -- | XRCP protocol errors forwarded to agent clients RCP {rcpErr :: RCErrorType} | -- | SMP server errors @@ -1516,22 +1520,6 @@ data ConnectionErrorType NOT_AVAILABLE deriving (Eq, Read, Show, Exception) --- | SMP server errors. -data BrokerErrorType - = -- | invalid server response (failed to parse) - RESPONSE {smpErr :: String} - | -- | unexpected response - UNEXPECTED - | -- | network error - NETWORK - | -- | no compatible server host (e.g. onion when public is required, or vice versa) - HOST - | -- | handshake or other transport error - TRANSPORT {transportErr :: TransportError} - | -- | command response timeout - TIMEOUT - deriving (Eq, Read, Show, Exception) - -- | Errors of another SMP agent. data SMPAgentError = -- | client or agent message that failed to parse @@ -1587,8 +1575,10 @@ instance StrEncoding AgentErrorType where <|> "SMP " *> (SMP <$> strP) <|> "NTF " *> (NTF <$> strP) <|> "XFTP " *> (XFTP <$> strP) + <|> "PROXY " *> (PROXY <$> textP <* A.space <*> textP <*> _strP) <|> "RCP " *> (RCP <$> strP) <|> "BROKER " *> (BROKER <$> textP <* " RESPONSE " <*> (RESPONSE <$> textP)) + <|> "BROKER " *> (BROKER <$> textP <* " UNEXPECTED " <*> (UNEXPECTED <$> textP)) <|> "BROKER " *> (BROKER <$> textP <* " TRANSPORT " <*> (TRANSPORT <$> transportErrorP)) <|> "BROKER " *> (BROKER <$> textP <* A.space <*> parseRead1) <|> "AGENT CRYPTO " *> (AGENT . A_CRYPTO <$> parseRead A.takeByteString) @@ -1605,8 +1595,10 @@ instance StrEncoding AgentErrorType where SMP e -> "SMP " <> strEncode e NTF e -> "NTF " <> strEncode e XFTP e -> "XFTP " <> strEncode e + PROXY pxy srv e -> B.unwords ["PROXY", text pxy, text srv, strEncode e] RCP e -> "RCP " <> strEncode e BROKER srv (RESPONSE e) -> "BROKER " <> text srv <> " RESPONSE " <> text e + BROKER srv (UNEXPECTED e) -> "BROKER " <> text srv <> " UNEXPECTED " <> text e BROKER srv (TRANSPORT e) -> "BROKER " <> text srv <> " TRANSPORT " <> serializeTransportError e BROKER srv e -> "BROKER " <> text srv <> " " <> bshow e AGENT (A_CRYPTO e) -> "AGENT CRYPTO " <> bshow e @@ -1977,8 +1969,6 @@ $(J.deriveJSON (sumTypeJSON id) ''CommandErrorType) $(J.deriveJSON (sumTypeJSON id) ''ConnectionErrorType) -$(J.deriveJSON (sumTypeJSON id) ''BrokerErrorType) - $(J.deriveJSON (sumTypeJSON id) ''AgentCryptoError) $(J.deriveJSON (sumTypeJSON id) ''SMPAgentError) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 2936e3841..8a1349089 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -64,6 +64,7 @@ module Simplex.Messaging.Client -- * Supporting types and client configuration ProtocolClientError (..), SMPClientError, + ProxyClientError (..), ProtocolClientConfig (..), NetworkConfig (..), TransportSessionMode (..), @@ -97,6 +98,7 @@ import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as J +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) @@ -534,16 +536,17 @@ temporaryClientError = \case _ -> False {-# INLINE temporaryClientError #-} +-- converts error of client running on proxy to the error sent to client connected to proxy smpProxyError :: SMPClientError -> ErrorType smpProxyError = \case - PCEProtocolError et -> PROXY (PROTOCOL et) - PCEResponseError et -> PROXY (RESPONSE et) - PCEUnexpectedResponse bs -> PROXY (UNEXPECTED $ B.unpack $ B.take 32 bs) - PCEResponseTimeout -> PROXY TIMEOUT - PCENetworkError -> PROXY NETWORK - PCEIncompatibleHost -> PROXY BAD_HOST - PCETransportError t -> PROXY (TRANSPORT t) - PCECryptoError _ -> INTERNAL + PCEProtocolError e -> PROXY $ PROTOCOL e + PCEResponseError e -> PROXY $ BROKER $ RESPONSE $ B.unpack $ strEncode e + PCEUnexpectedResponse s -> PROXY $ BROKER $ UNEXPECTED $ B.unpack $ B.take 32 s + PCEResponseTimeout -> PROXY $ BROKER TIMEOUT + PCENetworkError -> PROXY $ BROKER NETWORK + PCEIncompatibleHost -> PROXY $ BROKER HOST + PCETransportError t -> PROXY $ BROKER $ TRANSPORT t + PCECryptoError _ -> CRYPTO PCEIOError _ -> INTERNAL -- | Create a new SMP queue. @@ -699,21 +702,18 @@ deleteSMPQueues :: SMPClient -> NonEmpty (RcvPrivateAuthKey, RecipientId) -> IO deleteSMPQueues = okSMPCommands DEL {-# INLINE deleteSMPQueues #-} --- TODO picture - -- send PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- receives PKEY :: SessionId -> X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg connectSMPProxiedRelay :: SMPClient -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO ProxiedRelay connectSMPProxiedRelay c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth = sendSMPCommand c Nothing "" (PRXY relayServ proxyAuth) >>= \case - PKEY sId vr (chain, key) -> do + PKEY sId vr (chain, key) -> case supportedClientSMPRelayVRange `compatibleVersion` vr of - Nothing -> throwE PCEIncompatibleHost -- TODO different error - Just (Compatible v) -> liftEitherWith x509Error $ ProxiedRelay sId v <$> validateRelay chain key + Nothing -> throwE $ relayErr VERSION + Just (Compatible v) -> liftEitherWith (const $ relayErr IDENTITY) $ ProxiedRelay sId v <$> validateRelay chain key r -> throwE . PCEUnexpectedResponse $ bshow r where - x509Error :: String -> SMPClientError - x509Error _msg = PCEResponseError $ error "TODO: x509 error" -- TODO different error + relayErr = PCEProtocolError . PROXY . BROKER . TRANSPORT . TEHandshake validateRelay :: X.CertificateChain -> X.SignedExact X.PubKey -> Either String C.PublicKeyX25519 validateRelay (X.CertificateChain cert) exact = do serverKey <- case cert of @@ -730,10 +730,53 @@ data ProxiedRelay = ProxiedRelay prServerKey :: C.PublicKeyX25519 } +data ProxyClientError + = -- | protocol error response from proxy + ProxyProtocolError ErrorType + | -- | unexpexted response + ProxyUnexpectedResponse String + | -- | error between proxy and server + ProxyResponseError ErrorType + deriving (Eq, Show, Exception) + +instance StrEncoding ProxyClientError where + strEncode = \case + ProxyProtocolError e -> "PROTOCOL " <> strEncode e + ProxyUnexpectedResponse s -> "UNEXPECTED " <> B.pack s + ProxyResponseError e -> "SYNTAX " <> strEncode e + strP = + A.takeTill (== ' ') >>= \case + "PROTOCOL" -> ProxyProtocolError <$> _strP + "UNEXPECTED" -> ProxyUnexpectedResponse . B.unpack <$> (A.space *> A.takeByteString) + "SYNTAX" -> ProxyResponseError <$> _strP + _ -> fail "bad ProxyClientError" + -- consider how to process slow responses - is it handled somehow locally or delegated to the caller -- this method is used in the client -- sends PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command Sender -- receives PRES :: EncResponse -> BrokerMsg -- proxy to client + +-- When client sends message via proxy, there may be one successful scenario and 9 error scenarios +-- as shown below (WTF stands for unexpected response, ??? for response that failed to parse). +-- client proxy relay proxy client +-- 0) PFWD(SEND) -> RFWD -> RRES -> PRES(OK) -> ok +-- 1) PFWD(SEND) -> RFWD -> RRES -> PRES(ERR) -> PCEProtocolError - business logic error for client +-- 2) PFWD(SEND) -> RFWD -> RRES -> PRES(WTF) -> PCEUnexpectedReponse - relay/client protocol logic error +-- 3) PFWD(SEND) -> RFWD -> RRES -> PRES(???) -> PCEResponseError - relay/client syntax error +-- 4) PFWD(SEND) -> RFWD -> ERR -> ERR PROXY PROTOCOL -> ProxyProtocolError - proxy/relay business logic error +-- 5) PFWD(SEND) -> RFWD -> WTF -> ERR PROXY $ BROKER (UNEXPECTED s) -> ProxyProtocolError - proxy/relay protocol logic +-- 6) PFWD(SEND) -> RFWD -> ??? -> ERR PROXY $ BROKER (RESPONSE s) -> ProxyProtocolError - - proxy/relay syntax +-- 7) PFWD(SEND) -> ERR -> ProxyProtocolError - client/proxy business logic +-- 8) PFWD(SEND) -> WTF -> ProxyUnexpectedResponse - client/proxy protocol logic +-- 9) PFWD(SEND) -> ??? -> ProxyResponseError - client/proxy syntax +-- +-- We report as proxySMPMessage error (ExceptT error) the errors of two kinds: +-- - protocol errors from the destination relay wrapped in PRES - to simplify processing of AUTH and QUOTA errors, in this case proxy is "transparent" for such errors (PCEProtocolError, PCEUnexpectedResponse, PCEResponseError) +-- - other response/transport/connection errors from the client connected to proxy itself +-- Other errors are reported in the function result as `Either ProxiedRelayError ()`, including +-- - protocol errors from the client connected to proxy in ProxyClientError (PCEProtocolError, PCEUnexpectedResponse, PCEResponseError) +-- - other errors from the client running on proxy and connected to relay in PREProxiedRelayError + proxySMPMessage :: SMPClient -> -- proxy session from PKEY @@ -743,7 +786,7 @@ proxySMPMessage :: SenderId -> MsgFlags -> MsgBody -> - ExceptT SMPClientError IO () + ExceptT SMPClientError IO (Either ProxyClientError ()) -- TODO use version proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} (ProxiedRelay sessionId _v serverKey) spKey sId flags msg = do -- prepare params @@ -756,24 +799,32 @@ proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth serverThParams (CorrId corrId, sId, Cmd SSender $ SEND flags msg) auth <- liftEitherWith PCETransportError $ authTransmission serverThAuth spKey nonce tForAuth b <- case batchTransmissions (batch serverThParams) (blockSize serverThParams) [Right (auth, tToSend)] of - [] -> throwE $ PCETransportError TELargeMsg -- some other error. Internal? - TBError e _ : _ -> throwE $ PCETransportError e -- large message error? + [] -> throwE $ PCETransportError TELargeMsg + TBError e _ : _ -> throwE $ PCETransportError e TBTransmission s _ : _ -> pure s TBTransmissions s _ _ : _ -> pure s et <- liftEitherWith PCECryptoError $ EncTransmission <$> C.cbEncrypt cmdSecret nonce b paddedProxiedMsgLength - sendProtocolCommand_ c (Just nonce) Nothing sessionId (Cmd SProxiedClient (PFWD cmdPubKey et)) >>= \case - -- TODO support PKEY + resend? - PRES (EncResponse er) -> do - t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (C.reverseNonce nonce) er - case tParse proxyThParams t' of - t'' :| [] -> case tDecodeParseValidate proxyThParams t'' of - (_auth, _signed, (_c, _e, r)) -> case r of -- TODO: verify - Left e -> throwE $ PCEResponseError e - Right OK -> pure () - Right (ERR e) -> throwE $ PCEProtocolError e - Right u -> throwE . PCEUnexpectedResponse $ bshow u -- possibly differentiate unexpected response from server/proxy - _ -> throwE $ PCETransportError TEBadBlock - r -> throwE . PCEUnexpectedResponse $ bshow r -- from proxy + -- proxy interaction errors are wrapped + tryE (sendProtocolCommand_ c (Just nonce) Nothing sessionId (Cmd SProxiedClient (PFWD cmdPubKey et))) >>= \case + Right r -> case r of + PRES (EncResponse er) -> do + -- server interaction errors are thrown directly + t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (C.reverseNonce nonce) er + case tParse proxyThParams t' of + t'' :| [] -> case tDecodeParseValidate proxyThParams t'' of + (_auth, _signed, (_c, _e, cmd)) -> case cmd of + Right OK -> pure $ Right () + Right (ERR e) -> throwE $ PCEProtocolError e -- this is the error from the destination relay + Right e -> throwE $ PCEUnexpectedResponse $ B.take 32 $ bshow e + Left e -> throwE $ PCEResponseError e + _ -> throwE $ PCETransportError TEBadBlock + ERR e -> pure . Left $ ProxyProtocolError e -- this will not happen, this error is returned via Left + _ -> pure . Left $ ProxyUnexpectedResponse $ take 32 $ show r + Left e -> case e of + PCEProtocolError e' -> pure . Left $ ProxyProtocolError e' + PCEUnexpectedResponse r -> pure . Left $ ProxyUnexpectedResponse $ B.unpack r + PCEResponseError e' -> pure . Left $ ProxyResponseError e' + _ -> throwE e -- this method is used in the proxy -- sends RFWD :: EncFwdTransmission -> Command Sender @@ -783,8 +834,8 @@ forwardSMPMessage :: SMPClient -> CorrId -> C.PublicKeyX25519 -> EncTransmission forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = g}} fwdCorrId fwdKey fwdTransmission = do -- prepare params sessSecret <- case thAuth thParams of - Nothing -> throwError $ PCEProtocolError INTERNAL -- different error - proxy didn't pass key? - Just THAuthClient {sessSecret} -> maybe (throwError $ PCEProtocolError INTERNAL) pure sessSecret + Nothing -> throwError $ PCETransportError TENoServerAuth + Just THAuthClient {sessSecret} -> maybe (throwError $ PCETransportError TENoServerAuth) pure sessSecret nonce <- liftIO . atomically $ C.randomCbNonce g -- wrap let fwdT = FwdTransmission {fwdCorrId, fwdKey, fwdTransmission} @@ -796,7 +847,7 @@ forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = r' <- liftEitherWith PCECryptoError $ C.cbDecryptNoPad sessSecret (C.reverseNonce nonce) efr FwdResponse {fwdCorrId = _, fwdResponse} <- liftEitherWith (const $ PCEResponseError BLOCK) $ smpDecode r' pure fwdResponse - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE . PCEUnexpectedResponse $ B.take 32 $ bshow r okSMPCommand :: PartyI p => Command p -> SMPClient -> C.APrivateAuthKey -> QueueId -> ExceptT SMPClientError IO () okSMPCommand cmd c pKey qId = @@ -933,3 +984,5 @@ $(J.deriveJSON (enumJSON $ dropPrefix "TSM") ''TransportSessionMode) $(J.deriveJSON (enumJSON $ dropPrefix "SPM") ''SMPProxyMode) $(J.deriveJSON defaultJSON ''NetworkConfig) + +$(J.deriveJSON (enumJSON $ dropPrefix "Proxy") ''ProxyClientError) diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 118f3b084..d86d65251 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -66,6 +66,7 @@ module Simplex.Messaging.Protocol ErrorType (..), CommandError (..), ProxyError (..), + BrokerErrorType (..), Transmission, TransmissionAuth (..), SignedTransmission, @@ -176,6 +177,7 @@ module Simplex.Messaging.Protocol where import Control.Applicative (optional, (<|>)) +import Control.Exception (Exception) import Control.Monad import Control.Monad.Except import Data.Aeson (FromJSON (..), ToJSON (..)) @@ -194,8 +196,6 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Maybe (isJust, isNothing) import Data.String -import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock.System (SystemTime (..)) import Data.Type.Equality import Data.Word (Word16) @@ -211,7 +211,7 @@ import Simplex.Messaging.Parsers import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) -import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Util (bshow, eitherToMaybe, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal @@ -1144,6 +1144,8 @@ data ErrorType PROXY {proxyErr :: ProxyError} | -- | command authorization error - bad signature or non-existing SMP queue AUTH + | -- | encryption/decryption error in proxy protocol + CRYPTO | -- | SMP queue capacity is exceeded on the server QUOTA | -- | ACK command is sent without message to be acknowledged @@ -1186,19 +1188,32 @@ data CommandError data ProxyError = -- | Correctly parsed SMP server ERR response. - -- This error is forwarded to the agent client as `ERR SMP err`. + -- This error is forwarded to the agent client as AgentErrorType `ERR PROXY PROTOCOL err`. PROTOCOL {protocolErr :: ErrorType} - | -- | Invalid server response that failed to parse. - -- Forwarded to the agent client as `ERR BROKER RESPONSE`. - RESPONSE {responseErr :: ErrorType} - | UNEXPECTED {unexpectedResponse :: String} -- 'String' for using derived JSON and Arbitrary instances - | TIMEOUT - | NETWORK - | BAD_HOST - | NO_SESSION - | TRANSPORT {transportErr :: TransportError} + | -- | destination server error + BROKER {brokerErr :: BrokerErrorType} + | -- | basic auth provided to proxy is invalid + BASIC_AUTH + | -- no destination server error + NO_SESSION deriving (Eq, Read, Show) +-- | SMP server errors. +data BrokerErrorType + = -- | invalid server response (failed to parse) + RESPONSE {respErr :: String} + | -- | unexpected response + UNEXPECTED {respErr :: String} + | -- | network error + NETWORK + | -- | no compatible server host (e.g. onion when public is required, or vice versa) + HOST + | -- | handshake or other transport error + TRANSPORT {transportErr :: TransportError} + | -- | command response timeout + TIMEOUT + deriving (Eq, Read, Show, Exception) + -- | SMP transmission parser. transmissionP :: THandleParams v p -> Parser RawTransmission transmissionP THandleParams {sessionId, implySessId} = do @@ -1423,6 +1438,7 @@ instance Encoding ErrorType where CMD err -> "CMD " <> smpEncode err PROXY err -> "PROXY " <> smpEncode err AUTH -> "AUTH" + CRYPTO -> "CRYPTO" QUOTA -> "QUOTA" EXPIRED -> "EXPIRED" NO_MSG -> "NO_MSG" @@ -1437,13 +1453,14 @@ instance Encoding ErrorType where "CMD" -> CMD <$> _smpP "PROXY" -> PROXY <$> _smpP "AUTH" -> pure AUTH + "CRYPTO" -> pure CRYPTO "QUOTA" -> pure QUOTA "EXPIRED" -> pure EXPIRED "NO_MSG" -> pure NO_MSG "LARGE_MSG" -> pure LARGE_MSG "INTERNAL" -> pure INTERNAL "DUPLICATE_" -> pure DUPLICATE_ - _ -> fail "bad error type" + _ -> fail "bad ErrorType" instance Encoding CommandError where smpEncode e = case e of @@ -1462,45 +1479,71 @@ instance Encoding CommandError where "HAS_AUTH" -> pure HAS_AUTH "NO_ENTITY" -> pure NO_ENTITY "NO_QUEUE" -> pure NO_ENTITY -- for backward compatibility - _ -> fail "bad command error type" + _ -> fail "bad CommandError" instance Encoding ProxyError where - smpEncode e = case e of - PROTOCOL et -> "PROTOCOL " <> smpEncode et - RESPONSE et -> "RESPONSE " <> smpEncode et - UNEXPECTED s -> "UNEXPECTED " <> smpEncode (encodeUtf8 $ T.pack s) - TIMEOUT -> "TIMEOUT" - NETWORK -> "NETWORK" - BAD_HOST -> "BAD_HOST" + smpEncode = \case + PROTOCOL e -> "PROTOCOL " <> smpEncode e + BROKER e -> "BROKER " <> smpEncode e + BASIC_AUTH -> "BASIC_AUTH" NO_SESSION -> "NO_SESSION" - TRANSPORT t -> "TRANSPORT " <> serializeTransportError t smpP = A.takeTill (== ' ') >>= \case "PROTOCOL" -> PROTOCOL <$> _smpP - "RESPONSE" -> RESPONSE <$> _smpP - "UNEXPECTED" -> UNEXPECTED . (T.unpack . safeDecodeUtf8) <$> _smpP - "TIMEOUT" -> pure TIMEOUT - "NETWORK" -> pure NETWORK - "BAD_HOST" -> pure BAD_HOST + "BROKER" -> BROKER <$> _smpP + "BASIC_AUTH" -> pure BASIC_AUTH "NO_SESSION" -> pure NO_SESSION - "TRANSPORT" -> TRANSPORT <$> (A.space *> transportErrorP) - _ -> fail "bad command error type" + _ -> fail "bad ProxyError" instance StrEncoding ProxyError where strEncode = \case - PROTOCOL et -> "PROTOCOL " <> strEncode et - RESPONSE et -> "RESPONSE " <> strEncode et - UNEXPECTED "" -> "UNEXPECTED" -- Arbitrary instance generates empty strings which String instance can't handle - UNEXPECTED s -> "UNEXPECTED " <> strEncode s - TRANSPORT t -> "TRANSPORT " <> serializeTransportError t - e -> bshow e + PROTOCOL e -> "PROTOCOL " <> strEncode e + BROKER e -> "BROKER " <> strEncode e + BASIC_AUTH -> "BASIC_AUTH" + NO_SESSION -> "NO_SESSION" strP = - "PROTOCOL " *> (PROTOCOL <$> strP) - <|> "RESPONSE " *> (RESPONSE <$> strP) - <|> "UNEXPECTED " *> (UNEXPECTED <$> strP) - <|> "UNEXPECTED" $> UNEXPECTED "" - <|> "TRANSPORT " *> (TRANSPORT <$> transportErrorP) - <|> parseRead1 + A.takeTill (== ' ') >>= \case + "PROTOCOL" -> PROTOCOL <$> _strP + "BROKER" -> BROKER <$> _strP + "BASIC_AUTH" -> pure BASIC_AUTH + "NO_SESSION" -> pure NO_SESSION + _ -> fail "bad ProxyError" + +instance Encoding BrokerErrorType where + smpEncode = \case + RESPONSE e -> "RESPONSE " <> smpEncode e + UNEXPECTED e -> "UNEXPECTED " <> smpEncode e + TRANSPORT e -> "TRANSPORT " <> serializeTransportError e + NETWORK -> "NETWORK" + TIMEOUT -> "TIMEOUT" + HOST -> "HOST" + smpP = + A.takeTill (== ' ') >>= \case + "RESPONSE" -> RESPONSE <$> _smpP + "UNEXPECTED" -> UNEXPECTED <$> _smpP + "TRANSPORT" -> TRANSPORT <$> (A.space *> transportErrorP) + "NETWORK" -> pure NETWORK + "TIMEOUT" -> pure TIMEOUT + "HOST" -> pure HOST + _ -> fail "bad BrokerErrorType" + +instance StrEncoding BrokerErrorType where + strEncode = \case + RESPONSE e -> "RESPONSE " <> strEncode e + UNEXPECTED e -> "UNEXPECTED " <> strEncode e + TRANSPORT e -> "TRANSPORT " <> serializeTransportError e + NETWORK -> "NETWORK" + TIMEOUT -> "TIMEOUT" + HOST -> "HOST" + strP = + A.takeTill (== ' ') >>= \case + "RESPONSE" -> RESPONSE <$> _strP + "UNEXPECTED" -> UNEXPECTED <$> _strP + "TRANSPORT" -> TRANSPORT <$> (A.space *> transportErrorP) + "NETWORK" -> pure NETWORK + "TIMEOUT" -> pure TIMEOUT + "HOST" -> pure HOST + _ -> fail "bad BrokerErrorType" -- | Send signed SMP transmission to TCP transport. tPut :: Transport c => THandle v c p -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] @@ -1639,5 +1682,7 @@ $(J.deriveJSON defaultJSON ''MsgFlags) $(J.deriveJSON (sumTypeJSON id) ''CommandError) +$(J.deriveJSON (sumTypeJSON id) ''BrokerErrorType) + -- run deriveJSON in one TH splice to allow mutual instance $(concat <$> mapM @[] (J.deriveJSON (sumTypeJSON id)) [''ProxyError, ''ErrorType]) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index d1fcfbc24..0c0426958 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -54,7 +54,7 @@ import Data.Functor (($>)) import Data.Int (Int64) import qualified Data.IntMap.Strict as IM import Data.List (intercalate) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M import Data.Maybe (isNothing) @@ -68,7 +68,7 @@ import GHC.Stats (getRTSStats) import GHC.TypeLits (KnownNat) import Network.Socket (ServiceName, Socket, socketToHandle) import Simplex.Messaging.Agent.Lock -import Simplex.Messaging.Client (ProtocolClient (thParams), forwardSMPMessage, smpProxyError) +import Simplex.Messaging.Client (ProtocolClient (thParams), ProtocolClientError (..), forwardSMPMessage, smpProxyError) import Simplex.Messaging.Client.Agent (SMPClientAgent (..), SMPClientAgentEvent (..), getSMPServerClient', lookupSMPServerClient) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding @@ -616,28 +616,27 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi reply = atomically . writeTBQueue sndQ processProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (Transmission BrokerMsg) processProxiedCmd (corrId, sessId, command) = (corrId, sessId,) <$> case command of - PRXY srv auth -> ifM allowProxy getRelay (pure $ ERR AUTH) + PRXY srv auth -> ifM allowProxy getRelay (pure $ ERR $ PROXY BASIC_AUTH) where allowProxy = do ServerConfig {allowSMPProxy, newQueueBasicAuth} <- asks config pure $ allowSMPProxy && maybe True ((== auth) . Just) newQueueBasicAuth getRelay = do ProxyAgent {smpAgent} <- asks proxyAgent - -- TODO catch IO errors too - liftIO $ proxyResp <$> runExceptT (getSMPServerClient' smpAgent srv) + liftIO $ proxyResp <$> runExceptT (getSMPServerClient' smpAgent srv) `catch` (pure . Left . PCEIOError) where proxyResp = \case + Left err -> ERR $ smpProxyError err Right smp -> let THandleParams {sessionId = srvSessId, thAuth} = thParams smp vr = supportedServerSMPRelayVRange in case thAuth of Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey - Nothing -> ERR $ PROXY (TRANSPORT TENoServerAuth) - Left err -> ERR $ smpProxyError err + Nothing -> ERR . PROXY . BROKER $ TRANSPORT TENoServerAuth PFWD pubKey encBlock -> do ProxyAgent {smpAgent} <- asks proxyAgent atomically (lookupSMPServerClient smpAgent sessId) >>= \case - Just smp -> liftIO $ either (ERR . smpProxyError) PRES <$> runExceptT (forwardSMPMessage smp corrId pubKey encBlock) + Just smp -> liftIO $ either (ERR . smpProxyError) PRES <$> runExceptT (forwardSMPMessage smp corrId pubKey encBlock) `catchError` (pure . Left . PCEIOError) Nothing -> pure $ ERR $ PROXY NO_SESSION processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Either (Transmission (Command 'ProxiedClient)) (Transmission BrokerMsg)) processCommand (qr_, (corrId, queueId, cmd)) = do @@ -916,50 +915,48 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi pure . (cbNonce,) $ fromRight "" encNMsgMeta processForwardedCommand :: EncFwdTransmission -> M BrokerMsg - processForwardedCommand (EncFwdTransmission s) = fmap (either id id) . runExceptT $ do - -- TODO error - THAuthServer {serverPrivKey, sessSecret'} <- maybe (throwError $ ERR INTERNAL) pure thAuth - sessSecret <- maybe (throwError $ ERR INTERNAL) pure sessSecret' + processForwardedCommand (EncFwdTransmission s) = fmap (either ERR id) . runExceptT $ do + THAuthServer {serverPrivKey, sessSecret'} <- maybe (throwE noRelayAuth) pure (thAuth thParams') + sessSecret <- maybe (throwE noRelayAuth) pure sessSecret' let proxyNonce = C.cbNonce $ bs corrId - -- TODO error - s' <- liftEitherWith internalErr $ C.cbDecryptNoPad sessSecret proxyNonce s - -- TODO error - FwdTransmission {fwdCorrId, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith internalErr $ smpDecode s' - -- TODO error - this error is reported to proxy, as we failed to get to client's transmission + s' <- liftEitherWith (const CRYPTO) $ C.cbDecryptNoPad sessSecret proxyNonce s + FwdTransmission {fwdCorrId, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith (const $ CMD SYNTAX) $ smpDecode s' let clientSecret = C.dh' fwdKey serverPrivKey clientNonce = C.cbNonce $ bs fwdCorrId - b <- liftEitherWith internalErr $ C.cbDecrypt clientSecret clientNonce et + b <- liftEitherWith (const CRYPTO) $ C.cbDecrypt clientSecret clientNonce et -- only allowing single forwarded transactions - let t' = tDecodeParseValidate thParams' $ L.head $ tParse thParams' b - clntThAuth = Just $ THAuthServer {serverPrivKey, sessSecret' = Just clientSecret} - -- TODO error + t' <- case tParse thParams' b of + t :| [] -> pure $ tDecodeParseValidate thParams' t + _ -> throwE BLOCK + let clntThAuth = Just $ THAuthServer {serverPrivKey, sessSecret' = Just clientSecret} + -- process forwarded SEND r <- lift (rejectOrVerify clntThAuth t') >>= \case Left r -> pure r - Right t''@(_, (corrId', entId', _)) -> - -- Left will not be returned by processCommand, as only SEND command is allowed - fromRight (corrId', entId', ERR INTERNAL) <$> lift (processCommand t'') - + Right t''@(_, (corrId', entId', cmd')) -> case cmd' of + Cmd SSender SEND {} -> + -- Left will not be returned by processCommand, as only SEND command is allowed + fromRight (corrId', entId', ERR INTERNAL) <$> lift (processCommand t'') + _ -> + pure (corrId', entId', ERR $ CMD PROHIBITED) -- encode response r' <- case batchTransmissions (batch thParams') (blockSize thParams') [Right (Nothing, encodeTransmission thParams' r)] of - [] -> throwE $ ERR INTERNAL -- TODO error - TBError _ _ : _ -> throwE $ ERR INTERNAL -- TODO error + [] -> throwE INTERNAL -- at least 1 item is guaranteed from NonEmpty/Right + TBError _ _ : _ -> throwE BLOCK TBTransmission b' _ : _ -> pure b' TBTransmissions b' _ _ : _ -> pure b' -- encrypt to client - r2 <- liftEitherWith internalErr $ EncResponse <$> C.cbEncrypt clientSecret (C.reverseNonce clientNonce) r' paddedProxiedMsgLength + r2 <- liftEitherWith (const BLOCK) $ EncResponse <$> C.cbEncrypt clientSecret (C.reverseNonce clientNonce) r' paddedProxiedMsgLength -- encrypt to proxy let fr = FwdResponse {fwdCorrId, fwdResponse = r2} r3 = EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) pure $ RRES r3 where - internalErr _ = ERR INTERNAL -- TODO errors - THandleParams {thAuth} = thParams' + noRelayAuth = PROXY $ BROKER $ TRANSPORT TENoServerAuth rejectOrVerify :: Maybe (THandleAuth 'TServer) -> SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) rejectOrVerify clntThAuth (tAuth, authorized, (corrId', entId', cmdOrError)) = case cmdOrError of Left e -> pure $ Left (corrId', entId', ERR e) - -- flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody Right cmd'@(Cmd SSender SEND {}) -> verified <$> verifyTransmission ((,C.cbNonce (bs corrId')) <$> clntThAuth) tAuth authorized entId' cmd' where verified = \case diff --git a/tests/CoreTests/ProtocolErrorTests.hs b/tests/CoreTests/ProtocolErrorTests.hs index 8f5ad70e7..0bf60afdd 100644 --- a/tests/CoreTests/ProtocolErrorTests.hs +++ b/tests/CoreTests/ProtocolErrorTests.hs @@ -6,17 +6,15 @@ module CoreTests.ProtocolErrorTests where -import qualified Data.ByteString.Char8 as B -import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import Simplex.FileTransfer.Transport (XFTPErrorType (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as Agent +import Simplex.Messaging.Client (ProxyClientError (..)) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (CommandError (..), ErrorType (..), ProxyError (..)) +import Simplex.Messaging.Protocol (CommandError (..), ErrorType (..)) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport (HandshakeError (..), TransportError (..)) import Simplex.RemoteControl.Types (RCErrorType (..)) @@ -27,21 +25,32 @@ import Test.QuickCheck protocolErrorTests :: Spec protocolErrorTests = modifyMaxSuccess (const 1000) $ do describe "errors parsing / serializing" $ do - it "should parse SMP protocol errors" . property $ \(err :: ErrorType) -> + it "should parse SMP protocol errors" . property . forAll possibleErrorType $ \err -> smpDecode (smpEncode err) == Right err - it "should parse SMP agent errors" . property . forAll possible $ \err -> + it "should parse SMP agent errors" . property . forAll possibleAgentErrorType $ \err -> strDecode (strEncode err) == Right err where - possible :: Gen AgentErrorType - possible = + possibleErrorType :: Gen ErrorType + possibleErrorType = arbitrary >>= \e -> if skipErrorType e then discard else pure e + possibleAgentErrorType :: Gen AgentErrorType + possibleAgentErrorType = arbitrary >>= \case - BROKER srv (Agent.RESPONSE e) | hasSpaces srv || hasSpaces e -> discard - BROKER srv _ | hasSpaces srv -> discard - SMP (PROXY (SMP.UNEXPECTED s)) | hasUnicode s -> discard - NTF (PROXY (SMP.UNEXPECTED s)) | hasUnicode s -> discard + BROKER srv _ | skip srv -> discard + BROKER _ (RESPONSE e) | skip e -> discard + BROKER _ (UNEXPECTED e) | skip e -> discard + SMP e | skipErrorType e -> discard + NTF e | skipErrorType e -> discard + Agent.PROXY pxy srv _ | skip pxy || skip srv -> discard + Agent.PROXY _ _ (ProxyProtocolError e) | skipErrorType e -> discard + Agent.PROXY _ _ (ProxyUnexpectedResponse e) | skip e -> discard + Agent.PROXY _ _ (ProxyResponseError e) | skipErrorType e -> discard ok -> pure ok - hasSpaces s = ' ' `B.elem` encodeUtf8 (T.pack s) - hasUnicode = any (>= '\255') + skip s = null s || any (\c -> c <= ' ' || c >= '\255') s + skipErrorType = \case + SMP.PROXY (SMP.PROTOCOL e) -> skipErrorType e + SMP.PROXY (SMP.BROKER (UNEXPECTED s)) -> skip s + SMP.PROXY (SMP.BROKER (RESPONSE s)) -> skip s + _ -> False deriving instance Generic AgentErrorType @@ -49,6 +58,8 @@ deriving instance Generic CommandErrorType deriving instance Generic ConnectionErrorType +deriving instance Generic ProxyClientError + deriving instance Generic BrokerErrorType deriving instance Generic SMPAgentError @@ -59,7 +70,7 @@ deriving instance Generic ErrorType deriving instance Generic CommandError -deriving instance Generic ProxyError +deriving instance Generic SMP.ProxyError deriving instance Generic TransportError @@ -75,6 +86,8 @@ instance Arbitrary CommandErrorType where arbitrary = genericArbitraryU instance Arbitrary ConnectionErrorType where arbitrary = genericArbitraryU +instance Arbitrary ProxyClientError where arbitrary = genericArbitraryU + instance Arbitrary BrokerErrorType where arbitrary = genericArbitraryU instance Arbitrary SMPAgentError where arbitrary = genericArbitraryU @@ -85,7 +98,7 @@ instance Arbitrary ErrorType where arbitrary = genericArbitraryU instance Arbitrary CommandError where arbitrary = genericArbitraryU -instance Arbitrary ProxyError where arbitrary = genericArbitraryU +instance Arbitrary SMP.ProxyError where arbitrary = genericArbitraryU instance Arbitrary TransportError where arbitrary = genericArbitraryU diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index b70c88883..52145a992 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -113,7 +113,7 @@ deliverMessageViaProxy proxyServ relayServ alg msg msg' = do -- get proxy session sess <- connectSMPProxiedRelay pc relayServ (Just "correct") -- send via proxy to unsecured queue - proxySMPMessage pc sess Nothing sndId noMsgFlags msg + Right () <- proxySMPMessage pc sess Nothing sndId noMsgFlags msg -- receive 1 (_tSess, _v, _sid, _isResp, _entId, SMP.MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody}) <- atomically $ readTBQueue msgQ liftIO $ dec msgId encBody `shouldBe` Right msg @@ -122,7 +122,7 @@ deliverMessageViaProxy proxyServ relayServ alg msg msg' = do (sPub, sPriv) <- atomically $ C.generateAuthKeyPair alg g secureSMPQueue rc rPriv rcvId sPub -- send via proxy to secured queue - proxySMPMessage pc sess (Just sPriv) sndId noMsgFlags msg' + Right () <- proxySMPMessage pc sess (Just sPriv) sndId noMsgFlags msg' -- receive 2 (_tSess, _v, _sid, _isResp, _entId, SMP.MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'}) <- atomically $ readTBQueue msgQ liftIO $ dec msgId' encBody' `shouldBe` Right msg' @@ -171,14 +171,14 @@ testNoProxy = do withSmpServerConfigOn (transport @TLS) cfg testPort2 $ \_ -> do testSMPClient_ "127.0.0.1" testPort2 proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, _, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer Nothing) - reply `shouldBe` Right (SMP.ERR SMP.AUTH) + reply `shouldBe` Right (SMP.ERR $ SMP.PROXY SMP.BASIC_AUTH) testProxyAuth :: IO () testProxyAuth = do withSmpServerConfigOn (transport @TLS) proxyCfgAuth testPort $ \_ -> do testSMPClient_ "127.0.0.1" testPort proxyVRange $ \(th :: THandleSMP TLS 'TClient) -> do (_, _s, (_corrId, _entityId, reply)) <- sendRecv th (Nothing, "0", "", PRXY testSMPServer2 $ Just "wrong") - reply `shouldBe` Right (SMP.ERR SMP.AUTH) + reply `shouldBe` Right (SMP.ERR $ SMP.PROXY SMP.BASIC_AUTH) where proxyCfgAuth = proxyCfg {newQueueBasicAuth = Just "correct"} From a70f492f4dbb9ec997b39ab3ea89c2858b5dde73 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 7 May 2024 13:37:40 +0100 Subject: [PATCH 037/107] proxy: fallback to direct connection if destination relay does not support proxy protocol (#1132) * proxy: fallback to direct connection if destination relay does not support proxy protocol * move version to TransportError, refactor --- src/Simplex/FileTransfer/Client.hs | 4 +- src/Simplex/FileTransfer/Transport.hs | 5 +- src/Simplex/Messaging/Agent/Client.hs | 61 +++++++++----- src/Simplex/Messaging/Agent/Protocol.hs | 83 +++++++++++-------- src/Simplex/Messaging/Client.hs | 30 +++++-- .../Messaging/Notifications/Transport.hs | 5 +- src/Simplex/Messaging/Protocol.hs | 22 +++-- src/Simplex/Messaging/Server.hs | 27 ++++-- src/Simplex/Messaging/Transport.hs | 46 +++++----- tests/AgentTests/FunctionalAPITests.hs | 6 +- tests/CoreTests/ProtocolErrorTests.hs | 17 ++-- tests/SMPAgentClient.hs | 7 +- tests/SMPClient.hs | 5 +- tests/SMPProxyTests.hs | 20 ++++- tests/Test.hs | 2 +- 15 files changed, 212 insertions(+), 128 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index a788e39c2..4efff9388 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Protocol RecipientId, SenderId, ) -import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) +import Simplex.Messaging.Transport (ALPN, THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, alpn) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client @@ -114,7 +114,7 @@ getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, thParams@THandleParams {thVersion} <- case sessionALPN of Just "xftp/1" -> xftpClientHandshakeV1 serverVRange keyHash http2Client thParams0 Nothing -> pure thParams0 - _ -> throwError $ PCETransportError (TEHandshake VERSION) + _ -> throwError $ PCETransportError TEVersion logDebug $ "Client negotiated protocol: " <> tshow thVersion let c = XFTPClient {http2Client, thParams, transportSession, config} atomically $ writeTVar clientVar $ Just c diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index 27f1b8b95..244e00972 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -37,6 +37,7 @@ import qualified Control.Exception as E import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.Trans.Except import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (bimap, first) @@ -53,7 +54,7 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol (CommandError) -import Simplex.Messaging.Transport (HandshakeError (..), SessionId, THandle (..), THandleParams (..), TransportError (..), TransportPeer (..)) +import Simplex.Messaging.Transport (SessionId, THandle (..), THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.File import Simplex.Messaging.Util (bshow) import Simplex.Messaging.Version @@ -95,7 +96,7 @@ supportedFileServerVRange = mkVersionRange initialXFTPVersion currentXFTPVersion -- XFTP protocol does not use this handshake method xftpClientHandshakeStub :: c -> Maybe C.KeyPairX25519 -> C.KeyHash -> VersionRangeXFTP -> ExceptT TransportError IO (THandle XFTPVersion c 'TClient) -xftpClientHandshakeStub _c _ks _keyHash _xftpVRange = throwError $ TEHandshake VERSION +xftpClientHandshakeStub _c _ks _keyHash _xftpVRange = throwE TEVersion data XFTPServerHandshake = XFTPServerHandshake { xftpVersionRange :: VersionRangeXFTP, diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 35015af5a..ab9f3eb5f 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -147,6 +147,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import qualified Data.Aeson.TH as J @@ -231,7 +232,7 @@ import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (SMPVersion) +import Simplex.Messaging.Transport (SMPVersion, TransportError (..)) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util import Simplex.Messaging.Version @@ -577,7 +578,7 @@ getSMPServerClient c@AgentClient {active, smpClients, workerSeq} tSess = do prs <- atomically TM.empty smpConnectClient c tSess prs v -getSMPProxyClient :: AgentClient -> SMPTransportSession -> AM (SMPConnectedClient, ProxiedRelay) +getSMPProxyClient :: AgentClient -> SMPTransportSession -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq} destSess@(userId, destSrv, qId) = do unlessM (readTVarIO active) . throwError $ INACTIVE proxySrv <- getNextServer c userId [destSrv] @@ -589,7 +590,7 @@ getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq ProtoServerWithAuth srv auth <- TM.lookup destSess smpProxiedRelays >>= maybe (TM.insert destSess proxySrv smpProxiedRelays $> proxySrv) pure let tSess = (userId, srv, qId) (tSess,auth,) <$> getSessVar workerSeq tSess smpClients - newProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, ProxiedRelay) + newProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) newProxyClient tSess auth v = do (prs, rv) <- atomically $ do prs <- TM.empty @@ -598,32 +599,33 @@ getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq (prs,) . either id id <$> getSessVar workerSeq destSrv prs clnt <- smpConnectClient c tSess prs v (clnt,) <$> newProxiedRelay clnt auth rv - waitForProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, ProxiedRelay) + waitForProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) waitForProxyClient tSess auth v = do clnt@(SMPConnectedClient _ prs) <- waitForProtocolClient c tSess v sess <- atomically (getSessVar workerSeq destSrv prs) >>= either (newProxiedRelay clnt auth) (waitForProxiedRelay tSess) pure (clnt, sess) - newProxiedRelay :: SMPConnectedClient -> Maybe SMP.BasicAuth -> ProxiedRelayVar -> AM ProxiedRelay + newProxiedRelay :: SMPConnectedClient -> Maybe SMP.BasicAuth -> ProxiedRelayVar -> AM (Either AgentErrorType ProxiedRelay) newProxiedRelay clnt@(SMPConnectedClient smp prs) proxyAuth rv = tryAgentError (liftClient SMP (clientServer smp) $ connectSMPProxiedRelay smp destSrv proxyAuth) >>= \case Right sess -> do atomically $ putTMVar (sessionVar rv) (Right sess) liftIO $ incClientStat c userId clnt "PROXY" "OK" - pure sess + pure $ Right sess Left e -> do liftIO $ incClientStat c userId clnt "PROXY" $ strEncode e atomically $ do - removeSessVar rv destSrv prs - TM.delete destSess smpProxiedRelays + unless (persistentProxyError e) $ do + removeSessVar rv destSrv prs + TM.delete destSess smpProxiedRelays putTMVar (sessionVar rv) (Left e) - throwError e -- signal error to caller - waitForProxiedRelay :: SMPTransportSession -> ProxiedRelayVar -> AM ProxiedRelay + pure $ Left e + waitForProxiedRelay :: SMPTransportSession -> ProxiedRelayVar -> AM (Either AgentErrorType ProxiedRelay) waitForProxiedRelay (_, srv, _) rv = do NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c sess_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar rv) - liftEither $ case sess_ of + pure $ case sess_ of Just (Right sess) -> Right sess Just (Left e) -> Left e Nothing -> Left $ BROKER (B.unpack $ strEncode srv) TIMEOUT @@ -874,6 +876,7 @@ closeAgentClient c = do closeProtocolServerClients c smpClients closeProtocolServerClients c ntfClients closeProtocolServerClients c xftpClients + atomically $ writeTVar (smpProxiedRelays c) M.empty atomically (swapTVar (smpSubWorkers c) M.empty) >>= mapM_ cancelReconnect clearWorkers smpDeliveryWorkers >>= mapM_ (cancelWorker . fst) clearWorkers asyncCmdWorkers >>= mapM_ cancelWorker @@ -981,11 +984,14 @@ withClient_ c tSess@(userId, srv, _) statCmd action = do withProxySession :: AgentClient -> SMPTransportSession -> SMP.SenderId -> ByteString -> ((SMPConnectedClient, ProxiedRelay) -> AM a) -> AM a withProxySession c destSess@(userId, destSrv, _) entId cmdStr action = do - cp@(cl, _) <- getSMPProxyClient c destSess + (cl, sess_) <- getSMPProxyClient c destSess logServer ("--> " <> proxySrv cl <> " >") c destSrv entId cmdStr - r <- (action cp <* stat cl "OK") `catchAgentError` logServerError cl - logServer ("<-- " <> proxySrv cl <> " <") c destSrv entId "OK" - pure r + case sess_ of + Right sess -> do + r <- (action (cl, sess) <* stat cl "OK") `catchAgentError` logServerError cl + logServer ("<-- " <> proxySrv cl <> " <") c destSrv entId "OK" + pure r + Left e -> logServerError cl e where stat cl = liftIO . incClientStat c userId cl cmdStr proxySrv = showServer . protocolClientServer' . protocolClient @@ -1029,18 +1035,29 @@ sendOrProxySMPMessage c userId destSrv cmdStr spKey_ senderId msgFlags msg = do | ipAddressProtected cfg destSrv -> pure False | otherwise -> unknownServer SPMNever -> pure False + directAllowed = do + cfg <- getNetworkConfig c + pure $ case smpProxyFallback cfg of + SPFAllow -> True + SPFAllowProtected -> ipAddressProtected cfg destSrv + SPFProhibit -> False unknownServer = maybe True (all ((destSrv /=) . protoServer)) <$> TM.lookup userId (userServers c) - sendViaProxy destSess = - withProxySession c destSess senderId ("PFWD " <> cmdStr) $ \(SMPConnectedClient smp _, proxySess) -> do + sendViaProxy destSess = do + r <- tryAgentError . withProxySession c destSess senderId ("PFWD " <> cmdStr) $ \(SMPConnectedClient smp _, proxySess) -> do liftClient SMP (clientServer smp) (proxySMPMessage smp proxySess spKey_ senderId msgFlags msg) >>= \case Right () -> pure . Just $ protocolClientServer' smp Left proxyErr -> - throwError + throwE PROXY { proxyServer = protocolClientServer smp, relayServer = B.unpack $ strEncode destSrv, proxyErr } + case r of + Right r' -> pure r' + Left e + | persistentProxyError e -> ifM (atomically directAllowed) (sendDirectly destSess $> Nothing) (throwE e) + | otherwise -> throwE e sendDirectly tSess = withLogClient_ c tSess senderId ("SEND " <> cmdStr) $ \(SMPConnectedClient smp _) -> liftClient SMP (clientServer smp) $ sendSMPMessage smp spKey_ senderId msgFlags msg @@ -1280,7 +1297,6 @@ temporaryAgentError = \case NETWORK -> True TIMEOUT -> True _ -> False -{-# INLINE temporaryAgentError #-} temporaryOrHostError :: AgentErrorType -> Bool temporaryOrHostError = \case @@ -1288,7 +1304,12 @@ temporaryOrHostError = \case SMP (SMP.PROXY (SMP.BROKER HOST)) -> True PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER HOST))) -> True e -> temporaryAgentError e -{-# INLINE temporaryOrHostError #-} + +persistentProxyError :: AgentErrorType -> Bool +persistentProxyError = \case + BROKER _ (SMP.TRANSPORT TEVersion) -> True + SMP (SMP.PROXY (SMP.BROKER (SMP.TRANSPORT TEVersion))) -> True + _ -> False -- | Subscribe to queues. The list of results can have a different order. subscribeQueues :: AgentClient -> [RcvQueue] -> AM' [(RcvQueue, Either AgentErrorType ())] diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 61ab2400b..5cfd7af03 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -235,7 +235,7 @@ import Simplex.Messaging.Protocol ) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme -import Simplex.Messaging.Transport (Transport (..), serializeTransportError, transportErrorP) +import Simplex.Messaging.Transport (Transport (..)) import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts_ (..)) import Simplex.Messaging.Util import Simplex.Messaging.Version @@ -1554,12 +1554,14 @@ data AgentCryptoError instance StrEncoding AgentCryptoError where strP = - "DECRYPT_AES" $> DECRYPT_AES - <|> "DECRYPT_CB" $> DECRYPT_CB - <|> "RATCHET_HEADER" $> RATCHET_HEADER - <|> "RATCHET_EARLIER " *> (RATCHET_EARLIER <$> strP) - <|> "RATCHET_SKIPPED " *> (RATCHET_SKIPPED <$> strP) - <|> "RATCHET_SYNC" $> RATCHET_SYNC + A.takeTill (== ' ') >>= \case + "DECRYPT_AES" -> pure DECRYPT_AES + "DECRYPT_CB" -> pure DECRYPT_CB + "RATCHET_HEADER" -> pure RATCHET_HEADER + "RATCHET_EARLIER" -> RATCHET_EARLIER <$> _strP + "RATCHET_SKIPPED" -> RATCHET_SKIPPED <$> _strP + "RATCHET_SYNC" -> pure RATCHET_SYNC + _ -> fail "AgentCryptoError" strEncode = \case DECRYPT_AES -> "DECRYPT_AES" DECRYPT_CB -> "DECRYPT_CB" @@ -1570,25 +1572,24 @@ instance StrEncoding AgentCryptoError where instance StrEncoding AgentErrorType where strP = - "CMD " *> (CMD <$> parseRead1) - <|> "CONN " *> (CONN <$> parseRead1) - <|> "SMP " *> (SMP <$> strP) - <|> "NTF " *> (NTF <$> strP) - <|> "XFTP " *> (XFTP <$> strP) - <|> "PROXY " *> (PROXY <$> textP <* A.space <*> textP <*> _strP) - <|> "RCP " *> (RCP <$> strP) - <|> "BROKER " *> (BROKER <$> textP <* " RESPONSE " <*> (RESPONSE <$> textP)) - <|> "BROKER " *> (BROKER <$> textP <* " UNEXPECTED " <*> (UNEXPECTED <$> textP)) - <|> "BROKER " *> (BROKER <$> textP <* " TRANSPORT " <*> (TRANSPORT <$> transportErrorP)) - <|> "BROKER " *> (BROKER <$> textP <* A.space <*> parseRead1) - <|> "AGENT CRYPTO " *> (AGENT . A_CRYPTO <$> parseRead A.takeByteString) - <|> "AGENT QUEUE " *> (AGENT . A_QUEUE <$> parseRead A.takeByteString) - <|> "AGENT " *> (AGENT <$> parseRead1) - <|> "INTERNAL " *> (INTERNAL <$> parseRead A.takeByteString) - <|> "CRITICAL " *> (CRITICAL <$> parseRead1 <* A.space <*> parseRead A.takeByteString) - <|> "INACTIVE" $> INACTIVE + A.takeTill (== ' ') + >>= \case + "CMD" -> CMD <$> (A.space *> parseRead1) + "CONN" -> CONN <$> (A.space *> parseRead1) + "SMP" -> SMP <$> _strP + "NTF" -> NTF <$> _strP + "XFTP" -> XFTP <$> _strP + "PROXY" -> PROXY <$> (A.space *> srvP) <* A.space <*> srvP <*> _strP + "RCP" -> RCP <$> _strP + "BROKER" -> BROKER <$> (A.space *> srvP) <*> _strP + "AGENT" -> AGENT <$> _strP + "INTERNAL" -> INTERNAL <$> (A.space *> textP) + "CRITICAL" -> CRITICAL <$> (A.space *> parseRead1) <*> (A.space *> textP) + "INACTIVE" -> pure INACTIVE + _ -> fail "bad AgentErrorType" where - textP = T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ') + srvP = T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ') + textP = T.unpack . safeDecodeUtf8 <$> A.takeByteString strEncode = \case CMD e -> "CMD " <> bshow e CONN e -> "CONN " <> bshow e @@ -1597,19 +1598,33 @@ instance StrEncoding AgentErrorType where XFTP e -> "XFTP " <> strEncode e PROXY pxy srv e -> B.unwords ["PROXY", text pxy, text srv, strEncode e] RCP e -> "RCP " <> strEncode e - BROKER srv (RESPONSE e) -> "BROKER " <> text srv <> " RESPONSE " <> text e - BROKER srv (UNEXPECTED e) -> "BROKER " <> text srv <> " UNEXPECTED " <> text e - BROKER srv (TRANSPORT e) -> "BROKER " <> text srv <> " TRANSPORT " <> serializeTransportError e - BROKER srv e -> "BROKER " <> text srv <> " " <> bshow e - AGENT (A_CRYPTO e) -> "AGENT CRYPTO " <> bshow e - AGENT (A_QUEUE e) -> "AGENT QUEUE " <> bshow e - AGENT e -> "AGENT " <> bshow e - INTERNAL e -> "INTERNAL " <> bshow e - CRITICAL restart e -> "CRITICAL " <> bshow restart <> " " <> bshow e + BROKER srv e -> B.unwords ["BROKER", text srv, strEncode e] + AGENT e -> "AGENT " <> strEncode e + INTERNAL e -> "INTERNAL " <> encodeUtf8 (T.pack e) + CRITICAL restart e -> "CRITICAL " <> bshow restart <> " " <> encodeUtf8 (T.pack e) INACTIVE -> "INACTIVE" where text = encodeUtf8 . T.pack +instance StrEncoding SMPAgentError where + strP = + A.takeTill (== ' ') + >>= \case + "MESSAGE" -> pure A_MESSAGE + "PROHIBITED" -> pure A_PROHIBITED + "VERSION" -> pure A_VERSION + "CRYPTO" -> A_CRYPTO <$> _strP + "DUPLICATE" -> pure A_DUPLICATE + "QUEUE" -> A_QUEUE . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString) + _ -> fail "bad SMPAgentError" + strEncode = \case + A_MESSAGE -> "MESSAGE" + A_PROHIBITED -> "PROHIBITED" + A_VERSION -> "VERSION" + A_CRYPTO e -> "CRYPTO " <> strEncode e + A_DUPLICATE -> "DUPLICATE" + A_QUEUE e -> "QUEUE " <> encodeUtf8 (T.pack e) + cryptoErrToSyncState :: AgentCryptoError -> RatchetSyncState cryptoErrToSyncState = \case DECRYPT_AES -> RSAllowed diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 8a1349089..c38e56810 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -70,6 +70,7 @@ module Simplex.Messaging.Client TransportSessionMode (..), HostMode (..), SMPProxyMode (..), + SMPProxyFallback (..), defaultClientConfig, defaultSMPClientConfig, defaultNetworkConfig, @@ -224,6 +225,8 @@ data NetworkConfig = NetworkConfig sessionMode :: TransportSessionMode, -- | SMP proxy mode smpProxyMode :: SMPProxyMode, + -- | Fallback to direct connection when destination SMP relay does not support SMP proxy protocol extensions + smpProxyFallback :: SMPProxyFallback, -- | timeout for the initial client TCP/TLS connection (microseconds) tcpConnectTimeout :: Int, -- | timeout of protocol commands (microseconds) @@ -253,6 +256,12 @@ data SMPProxyMode | SPMNever deriving (Eq, Show) +data SMPProxyFallback + = SPFAllow -- connect directly when chosen proxy or destination relay do not support proxy protocol. + | SPFAllowProtected -- connect directly only when IP address is protected (SOCKS proxy or .onion address is used). + | SPFProhibit -- prohibit direct connection to destination relay. + deriving (Eq, Show) + defaultNetworkConfig :: NetworkConfig defaultNetworkConfig = NetworkConfig @@ -261,6 +270,7 @@ defaultNetworkConfig = requiredHostMode = False, sessionMode = TSMUser, smpProxyMode = SPMNever, + smpProxyFallback = SPFAllow, tcpConnectTimeout = defaultTcpConnectTimeout, tcpTimeout = 15_000_000, tcpTimeoutPerKb = 5_000, @@ -705,15 +715,17 @@ deleteSMPQueues = okSMPCommands DEL -- send PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- receives PKEY :: SessionId -> X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg connectSMPProxiedRelay :: SMPClient -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO ProxiedRelay -connectSMPProxiedRelay c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth = - sendSMPCommand c Nothing "" (PRXY relayServ proxyAuth) >>= \case - PKEY sId vr (chain, key) -> - case supportedClientSMPRelayVRange `compatibleVersion` vr of - Nothing -> throwE $ relayErr VERSION - Just (Compatible v) -> liftEitherWith (const $ relayErr IDENTITY) $ ProxiedRelay sId v <$> validateRelay chain key - r -> throwE . PCEUnexpectedResponse $ bshow r +connectSMPProxiedRelay c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth + | thVersion (thParams c) >= sendingProxySMPVersion = + sendSMPCommand c Nothing "" (PRXY relayServ proxyAuth) >>= \case + PKEY sId vr (chain, key) -> + case supportedClientSMPRelayVRange `compatibleVersion` vr of + Nothing -> throwE $ transportErr TEVersion + Just (Compatible v) -> liftEitherWith (const $ transportErr $ TEHandshake IDENTITY) $ ProxiedRelay sId v <$> validateRelay chain key + r -> throwE . PCEUnexpectedResponse $ bshow r + | otherwise = throwE $ PCETransportError TEVersion where - relayErr = PCEProtocolError . PROXY . BROKER . TRANSPORT . TEHandshake + transportErr = PCEProtocolError . PROXY . BROKER . TRANSPORT validateRelay :: X.CertificateChain -> X.SignedExact X.PubKey -> Either String C.PublicKeyX25519 validateRelay (X.CertificateChain cert) exact = do serverKey <- case cert of @@ -983,6 +995,8 @@ $(J.deriveJSON (enumJSON $ dropPrefix "TSM") ''TransportSessionMode) $(J.deriveJSON (enumJSON $ dropPrefix "SPM") ''SMPProxyMode) +$(J.deriveJSON (enumJSON $ dropPrefix "SPF") ''SMPProxyFallback) + $(J.deriveJSON defaultJSON ''NetworkConfig) $(J.deriveJSON (enumJSON $ dropPrefix "Proxy") ''ProxyClientError) diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index e2c287437..564b0c0df 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -11,6 +11,7 @@ module Simplex.Messaging.Notifications.Transport where import Control.Monad (forM) import Control.Monad.Except +import Control.Monad.Trans.Except import Data.Attoparsec.ByteString.Char8 (Parser) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -111,7 +112,7 @@ ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do throwError $ TEHandshake IDENTITY | v `isCompatible` ntfVRange -> pure $ ntfThHandleServer th v pk - | otherwise -> throwError $ TEHandshake VERSION + | otherwise -> throwE TEVersion -- | Notifcations server client transport handshake. ntfClientHandshake :: forall c. Transport c => c -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TClient) @@ -128,7 +129,7 @@ ntfClientHandshake c keyHash ntfVRange = do (,(getServerCerts c, signedKey)) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) sendHandshake th $ NtfClientHandshake {ntfVersion = v, keyHash} pure $ ntfThHandleClient th v ck_ - Nothing -> throwError $ TEHandshake VERSION + Nothing -> throwE TEVersion ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> C.PrivateKeyX25519 -> THandleNTF c 'TServer ntfThHandleServer th v pk = diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index d86d65251..250c76fcf 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -196,6 +196,8 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Maybe (isJust, isNothing) import Data.String +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock.System (SystemTime (..)) import Data.Type.Equality import Data.Word (Word16) @@ -211,7 +213,7 @@ import Simplex.Messaging.Parsers import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) -import Simplex.Messaging.Util (bshow, eitherToMaybe, (<$?>)) +import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal @@ -1513,7 +1515,7 @@ instance Encoding BrokerErrorType where smpEncode = \case RESPONSE e -> "RESPONSE " <> smpEncode e UNEXPECTED e -> "UNEXPECTED " <> smpEncode e - TRANSPORT e -> "TRANSPORT " <> serializeTransportError e + TRANSPORT e -> "TRANSPORT " <> smpEncode e NETWORK -> "NETWORK" TIMEOUT -> "TIMEOUT" HOST -> "HOST" @@ -1521,7 +1523,7 @@ instance Encoding BrokerErrorType where A.takeTill (== ' ') >>= \case "RESPONSE" -> RESPONSE <$> _smpP "UNEXPECTED" -> UNEXPECTED <$> _smpP - "TRANSPORT" -> TRANSPORT <$> (A.space *> transportErrorP) + "TRANSPORT" -> TRANSPORT <$> _smpP "NETWORK" -> pure NETWORK "TIMEOUT" -> pure TIMEOUT "HOST" -> pure HOST @@ -1529,21 +1531,23 @@ instance Encoding BrokerErrorType where instance StrEncoding BrokerErrorType where strEncode = \case - RESPONSE e -> "RESPONSE " <> strEncode e - UNEXPECTED e -> "UNEXPECTED " <> strEncode e - TRANSPORT e -> "TRANSPORT " <> serializeTransportError e + RESPONSE e -> "RESPONSE " <> encodeUtf8 (T.pack e) + UNEXPECTED e -> "UNEXPECTED " <> encodeUtf8 (T.pack e) + TRANSPORT e -> "TRANSPORT " <> smpEncode e NETWORK -> "NETWORK" TIMEOUT -> "TIMEOUT" HOST -> "HOST" strP = A.takeTill (== ' ') >>= \case - "RESPONSE" -> RESPONSE <$> _strP - "UNEXPECTED" -> UNEXPECTED <$> _strP - "TRANSPORT" -> TRANSPORT <$> (A.space *> transportErrorP) + "RESPONSE" -> RESPONSE <$> _textP + "UNEXPECTED" -> UNEXPECTED <$> _textP + "TRANSPORT" -> TRANSPORT <$> _smpP "NETWORK" -> pure NETWORK "TIMEOUT" -> pure TIMEOUT "HOST" -> pure HOST _ -> fail "bad BrokerErrorType" + where + _textP = A.space *> (T.unpack . safeDecodeUtf8 <$> A.takeByteString) -- | Send signed SMP transmission to TCP transport. tPut :: Transport c => THandle v c p -> NonEmpty (Either TransportError SentRawTransmission) -> IO [Either TransportError ()] diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 0c0426958..4195bffba 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -628,16 +628,26 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi proxyResp = \case Left err -> ERR $ smpProxyError err Right smp -> - let THandleParams {sessionId = srvSessId, thAuth} = thParams smp - vr = supportedServerSMPRelayVRange - in case thAuth of - Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey - Nothing -> ERR . PROXY . BROKER $ TRANSPORT TENoServerAuth + let THandleParams {sessionId = srvSessId, thVersion, thAuth} = thParams smp + vr = supportedServerSMPRelayVRange -- TODO this should be destination relay version range + in if thVersion >= sendingProxySMPVersion + then case thAuth of + Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey + Nothing -> ERR $ transportErr TENoServerAuth + else ERR $ transportErr TEVersion PFWD pubKey encBlock -> do ProxyAgent {smpAgent} <- asks proxyAgent atomically (lookupSMPServerClient smpAgent sessId) >>= \case - Just smp -> liftIO $ either (ERR . smpProxyError) PRES <$> runExceptT (forwardSMPMessage smp corrId pubKey encBlock) `catchError` (pure . Left . PCEIOError) + Just smp + | v >= sendingProxySMPVersion -> + liftIO $ either (ERR . smpProxyError) PRES <$> + runExceptT (forwardSMPMessage smp corrId pubKey encBlock) `catchError` (pure . Left . PCEIOError) + | otherwise -> pure . ERR $ transportErr TEVersion + where + THandleParams {thVersion = v} = thParams smp Nothing -> pure $ ERR $ PROXY NO_SESSION + transportErr :: TransportError -> ErrorType + transportErr = PROXY . BROKER . TRANSPORT processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Either (Transmission (Command 'ProxiedClient)) (Transmission BrokerMsg)) processCommand (qr_, (corrId, queueId, cmd)) = do st <- asks queueStore @@ -916,8 +926,8 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi processForwardedCommand :: EncFwdTransmission -> M BrokerMsg processForwardedCommand (EncFwdTransmission s) = fmap (either ERR id) . runExceptT $ do - THAuthServer {serverPrivKey, sessSecret'} <- maybe (throwE noRelayAuth) pure (thAuth thParams') - sessSecret <- maybe (throwE noRelayAuth) pure sessSecret' + THAuthServer {serverPrivKey, sessSecret'} <- maybe (throwE $ transportErr TENoServerAuth) pure (thAuth thParams') + sessSecret <- maybe (throwE $ transportErr TENoServerAuth) pure sessSecret' let proxyNonce = C.cbNonce $ bs corrId s' <- liftEitherWith (const CRYPTO) $ C.cbDecryptNoPad sessSecret proxyNonce s FwdTransmission {fwdCorrId, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith (const $ CMD SYNTAX) $ smpDecode s' @@ -952,7 +962,6 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi r3 = EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) pure $ RRES r3 where - noRelayAuth = PROXY $ BROKER $ TRANSPORT TENoServerAuth rejectOrVerify :: Maybe (THandleAuth 'TServer) -> SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) rejectOrVerify clntThAuth (tAuth, authorized, (corrId', entId', cmdOrError)) = case cmdOrError of diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 6f0f04ff7..6d1f05852 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -72,14 +72,12 @@ module Simplex.Messaging.Transport smpClientHandshake, tPutBlock, tGetBlock, - serializeTransportError, - transportErrorP, sendHandshake, getHandshake, ) where -import Control.Applicative (optional, (<|>)) +import Control.Applicative (optional) import Control.Monad (forM) import Control.Monad.Except import Control.Monad.Trans.Except (throwE) @@ -410,6 +408,8 @@ authEncryptCmdsP v p = if v >= authCmdsSMPVersion then optional p else pure Noth data TransportError = -- | error parsing transport block TEBadBlock + | -- | incompatible client or server version + TEVersion | -- | message does not fit in transport block TELargeMsg | -- | incorrect session ID @@ -425,31 +425,29 @@ data TransportError data HandshakeError = -- | parsing error PARSE - | -- | incompatible peer version - VERSION | -- | incorrect server identity IDENTITY | -- | v7 authentication failed BAD_AUTH deriving (Eq, Read, Show, Exception) --- | SMP encrypted transport error parser. -transportErrorP :: Parser TransportError -transportErrorP = - "BLOCK" $> TEBadBlock - <|> "LARGE_MSG" $> TELargeMsg - <|> "SESSION" $> TEBadSession - <|> "NO_AUTH" $> TENoServerAuth - <|> "HANDSHAKE " *> (TEHandshake <$> parseRead1) - --- | Serialize SMP encrypted transport error. -serializeTransportError :: TransportError -> ByteString -serializeTransportError = \case - TEBadBlock -> "BLOCK" - TELargeMsg -> "LARGE_MSG" - TEBadSession -> "SESSION" - TENoServerAuth -> "NO_AUTH" - TEHandshake e -> "HANDSHAKE " <> bshow e +instance Encoding TransportError where + smpP = + A.takeTill (== ' ') >>= \case + "BLOCK" -> pure TEBadBlock + "VERSION" -> pure TEVersion + "LARGE_MSG" -> pure TELargeMsg + "SESSION" -> pure TEBadSession + "NO_AUTH" -> pure TENoServerAuth + "HANDSHAKE" -> TEHandshake <$> (A.space *> parseRead1) + _ -> fail "bad TransportError" + smpEncode = \case + TEBadBlock -> "BLOCK" + TEVersion -> "VERSION" + TELargeMsg -> "LARGE_MSG" + TEBadSession -> "SESSION" + TENoServerAuth -> "NO_AUTH" + TEHandshake e -> "HANDSHAKE " <> bshow e -- | Pad and send block to SMP transport. tPutBlock :: Transport c => THandle v c p -> ByteString -> IO (Either TransportError ()) @@ -480,7 +478,7 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do throwE $ TEHandshake IDENTITY | v `isCompatible` smpVRange -> pure $ smpThHandleServer th v pk k' - | otherwise -> throwE $ TEHandshake VERSION + | otherwise -> throwE TEVersion -- | Client SMP transport handshake. -- @@ -503,7 +501,7 @@ smpClientHandshake c ks_ keyHash@(C.KeyHash kh) smpVRange = do (,certKey) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = fst <$> ks_} pure $ smpThHandleClient th v (snd <$> ks_) ck_ - Nothing -> throwE $ TEHandshake VERSION + Nothing -> throwE TEVersion smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer smpThHandleServer th v pk k_ = diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 05c461f6e..64447da23 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -81,7 +81,7 @@ import qualified Simplex.Messaging.Agent.Protocol as A import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') -import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), SMPProxyMode (..), TransportSessionMode (TSMEntity, TSMUser), defaultSMPClientConfig) +import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), SMPProxyFallback (..), SMPProxyMode (..), TransportSessionMode (TSMEntity, TSMUser), defaultSMPClientConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR @@ -454,7 +454,7 @@ canCreateQueue allowNew (srvAuth, srvVersion) (clntAuth, clntVersion) = testMatrix2 :: ATransport -> (PQSupport -> Bool -> AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> Spec testMatrix2 t runTest = do - it "v8, via proxy" $ withSmpServerProxy t $ runTestCfgServers2 agentProxyCfg agentProxyCfg (initAgentServersProxy SPMAlways) 3 $ runTest PQSupportOn True + it "v8, via proxy" $ withSmpServerProxy t $ runTestCfgServers2 agentProxyCfg agentProxyCfg (initAgentServersProxy SPMAlways SPFProhibit) 3 $ runTest PQSupportOn True it "v7" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfgV7 3 $ runTest PQSupportOn False it "v7 to current" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfg 3 $ runTest PQSupportOn False it "current to v7" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfgV7 3 $ runTest PQSupportOn False @@ -466,7 +466,7 @@ testMatrix2 t runTest = do testRatchetMatrix2 :: ATransport -> (PQSupport -> Bool -> AgentClient -> AgentClient -> AgentMsgId -> IO ()) -> Spec testRatchetMatrix2 t runTest = do - it "v8, via proxy" $ withSmpServerProxy t $ runTestCfgServers2 agentProxyCfg agentProxyCfg (initAgentServersProxy SPMAlways) 3 $ runTest PQSupportOn True + it "v8, via proxy" $ withSmpServerProxy t $ runTestCfgServers2 agentProxyCfg agentProxyCfg (initAgentServersProxy SPMAlways SPFProhibit) 3 $ runTest PQSupportOn True it "ratchet next" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfgV7 3 $ runTest PQSupportOn False it "ratchet next to current" $ withSmpServerV7 t $ runTestCfg2 agentCfgV7 agentCfg 3 $ runTest PQSupportOn False it "ratchet current to next" $ withSmpServerV7 t $ runTestCfg2 agentCfg agentCfgV7 3 $ runTest PQSupportOn False diff --git a/tests/CoreTests/ProtocolErrorTests.hs b/tests/CoreTests/ProtocolErrorTests.hs index 0bf60afdd..af13ba030 100644 --- a/tests/CoreTests/ProtocolErrorTests.hs +++ b/tests/CoreTests/ProtocolErrorTests.hs @@ -35,21 +35,22 @@ protocolErrorTests = modifyMaxSuccess (const 1000) $ do possibleAgentErrorType :: Gen AgentErrorType possibleAgentErrorType = arbitrary >>= \case - BROKER srv _ | skip srv -> discard - BROKER _ (RESPONSE e) | skip e -> discard - BROKER _ (UNEXPECTED e) | skip e -> discard + BROKER srv _ | hasSpaces srv -> discard SMP e | skipErrorType e -> discard NTF e | skipErrorType e -> discard - Agent.PROXY pxy srv _ | skip pxy || skip srv -> discard + Agent.PROXY pxy srv _ | hasSpaces pxy || hasSpaces srv -> discard Agent.PROXY _ _ (ProxyProtocolError e) | skipErrorType e -> discard - Agent.PROXY _ _ (ProxyUnexpectedResponse e) | skip e -> discard + Agent.PROXY _ _ (ProxyUnexpectedResponse e) | hasUnicode e -> discard Agent.PROXY _ _ (ProxyResponseError e) | skipErrorType e -> discard ok -> pure ok - skip s = null s || any (\c -> c <= ' ' || c >= '\255') s + hasSpaces :: String -> Bool + hasSpaces = any (== ' ') + hasUnicode :: String -> Bool + hasUnicode = any (>= '\255') skipErrorType = \case SMP.PROXY (SMP.PROTOCOL e) -> skipErrorType e - SMP.PROXY (SMP.BROKER (UNEXPECTED s)) -> skip s - SMP.PROXY (SMP.BROKER (RESPONSE s)) -> skip s + SMP.PROXY (SMP.BROKER (UNEXPECTED s)) -> hasUnicode s + SMP.PROXY (SMP.BROKER (RESPONSE s)) -> hasUnicode s _ -> False deriving instance Generic AgentErrorType diff --git a/tests/SMPAgentClient.hs b/tests/SMPAgentClient.hs index b18b264e1..3cf09e5db 100644 --- a/tests/SMPAgentClient.hs +++ b/tests/SMPAgentClient.hs @@ -35,7 +35,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Server (runSMPAgentBlocking) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') -import Simplex.Messaging.Client (ProtocolClientConfig (..), SMPProxyMode, chooseTransportHost, defaultNetworkConfig, defaultSMPClientConfig) +import Simplex.Messaging.Client (ProtocolClientConfig (..), SMPProxyFallback, SMPProxyMode, chooseTransportHost, defaultNetworkConfig, defaultSMPClientConfig) import Simplex.Messaging.Notifications.Client (defaultNTFClientConfig) import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (NtfServer, ProtoServerWithAuth) @@ -199,8 +199,9 @@ initAgentServers = initAgentServers2 :: InitialAgentServers initAgentServers2 = initAgentServers {smp = userServers [noAuthSrv testSMPServer, noAuthSrv testSMPServer2]} -initAgentServersProxy :: SMPProxyMode -> InitialAgentServers -initAgentServersProxy smpProxyMode = initAgentServers {netCfg = (netCfg initAgentServers) {smpProxyMode}} +initAgentServersProxy :: SMPProxyMode -> SMPProxyFallback -> InitialAgentServers +initAgentServersProxy smpProxyMode smpProxyFallback = + initAgentServers {netCfg = (netCfg initAgentServers) {smpProxyMode, smpProxyFallback}} agentCfg :: AgentConfig agentCfg = diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index e27970608..3d9d77033 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -118,10 +118,13 @@ cfg = cfgV7 :: ServerConfig cfgV7 = cfg {smpServerVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} +cfgV8 :: ServerConfig +cfgV8 = cfg {smpServerVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} + proxyCfg :: ServerConfig proxyCfg = cfgV7 - { allowSMPProxy = True, + { allowSMPProxy = True, smpServerVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = proxyVRange, agreeSecret = True}} } diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 52145a992..a9c0c5fb1 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -14,6 +14,7 @@ module SMPProxyTests where import AgentTests.FunctionalAPITests +import Control.Monad.Trans.Except (runExceptT) import Data.ByteString.Char8 (ByteString) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L @@ -87,10 +88,14 @@ smpProxyTests = do agentDeliverMessageViaProxy ([srv1], SPMNever, False) ([srv2], SPMNever, False) C.SEd448 "hello 1" "hello 2" it "first via proxy for unknown" . twoServers $ agentDeliverMessageViaProxy ([srv1], SPMUnknown, True) ([srv1, srv2], SPMUnknown, False) C.SEd448 "hello 1" "hello 2" + it "without proxy with fallback" . twoServers_ proxyCfg cfgV7 $ + agentDeliverMessageViaProxy ([srv1], SPMUnknown, False) ([srv2], SPMUnknown, False) C.SEd448 "hello 1" "hello 2" + it "fails when fallback is prohibited" . twoServers_ proxyCfg cfgV7 $ + agentViaProxyVersionError where oneServer = withSmpServerConfigOn (transport @TLS) proxyCfg testPort . const twoServers = twoServers_ proxyCfg proxyCfg - twoServersFirstProxy = twoServers_ proxyCfg cfgV7 + twoServersFirstProxy = twoServers_ proxyCfg cfgV8 twoServers_ cfg1 cfg2 runTest = withSmpServerConfigOn (transport @TLS) cfg1 testPort $ \_ -> withSmpServerConfigOn (transport @TLS) cfg2 testPort2 $ const runTest @@ -164,7 +169,18 @@ agentDeliverMessageViaProxy aTestCfg@(aSrvs, _, aViaProxy) bTestCfg@(bSrvs, _, b baseId = 3 msgId = subtract baseId . fst aCfg = agentProxyCfg {sndAuthAlg = C.AuthAlg alg, rcvAuthAlg = C.AuthAlg alg} - servers (srvs, smpProxyMode, _) = (initAgentServersProxy smpProxyMode) {smp = userServers $ L.map noAuthSrv srvs} + servers (srvs, smpProxyMode, _) = (initAgentServersProxy smpProxyMode SPFAllow) {smp = userServers $ L.map noAuthSrv srvs} + +agentViaProxyVersionError :: IO () +agentViaProxyVersionError = + withAgent 1 agentProxyCfg (servers [SMPServer testHost testPort testKeyHash]) testDB $ \alice -> do + Left (A.BROKER _ (TRANSPORT TEVersion)) <- + withAgent 2 agentProxyCfg (servers [SMPServer testHost testPort2 testKeyHash]) testDB2 $ \bob -> runExceptT $ do + (_bobId, qInfo) <- A.createConnection alice 1 True SCMInvitation Nothing (CR.IKNoPQ PQSupportOn) SMSubscribe + A.joinConnection bob 1 Nothing True qInfo "bob's connInfo" PQSupportOn SMSubscribe + pure () + where + servers srvs = (initAgentServersProxy SPMUnknown SPFProhibit) {smp = userServers $ L.map noAuthSrv srvs} testNoProxy :: IO () testNoProxy = do diff --git a/tests/Test.hs b/tests/Test.hs index cd2b0d8c3..f9fb2a2c0 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -47,7 +47,7 @@ main = do $ do describe "Agent SQLite schema dump" schemaDumpTest describe "Core tests" $ do - xdescribe "Batching tests" batchingTests + describe "Batching tests" batchingTests describe "Encoding tests" encodingTests describe "Protocol error tests" protocolErrorTests describe "Version range" versionRangeTests From 7a07076277d69f701806c5233fe884efcc90e952 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 8 May 2024 02:06:09 +0300 Subject: [PATCH 038/107] transport: require ALPN for extended handshakes (#1134) * transport: require ALPN for extended handshakes * fix 8.10 build * rename --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/FileTransfer/Server/Env.hs | 17 ++------------- src/Simplex/Messaging/Agent/Server.hs | 2 +- src/Simplex/Messaging/Client.hs | 12 ++++++----- src/Simplex/Messaging/Notifications/Client.hs | 4 ++-- .../Messaging/Notifications/Server/Env.hs | 6 +++--- .../Messaging/Notifications/Server/Main.hs | 5 +++-- .../Messaging/Notifications/Transport.hs | 11 ++++++++-- src/Simplex/Messaging/Server/Env/STM.hs | 8 +++---- src/Simplex/Messaging/Server/Main.hs | 5 +++-- src/Simplex/Messaging/Transport.hs | 21 +++++++++++++++++-- .../Messaging/Transport/HTTP2/Server.hs | 2 +- src/Simplex/Messaging/Transport/Server.hs | 14 +++++++------ src/Simplex/Messaging/Transport/WebSockets.hs | 10 +++++++-- tests/AgentTests/FunctionalAPITests.hs | 17 +++++++++------ tests/NtfClient.hs | 5 ++++- tests/SMPClient.hs | 13 +++++++++--- 16 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index b7f60c9af..414bfb4c4 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -13,12 +13,9 @@ import Control.Logger.Simple import Control.Monad import Control.Monad.IO.Unlift import Crypto.Random -import Data.Default (def) import Data.Int (Int64) -import Data.List (find) import Data.List.NonEmpty (NonEmpty) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) import Data.Time.Clock (getCurrentTime) import Data.Word (Word32) import Data.X509.Validation (Fingerprint (..)) @@ -103,7 +100,7 @@ supportedXFTPhandshakes :: [ALPN] supportedXFTPhandshakes = ["xftp/1"] newXFTPServerEnv :: XFTPServerConfig -> IO XFTPEnv -newXFTPServerEnv config@XFTPServerConfig {storeLogFile, fileSizeQuota, caCertificateFile, certificateFile, privateKeyFile} = do +newXFTPServerEnv config@XFTPServerConfig {storeLogFile, fileSizeQuota, caCertificateFile, certificateFile, privateKeyFile, transportConfig} = do random <- liftIO C.newRandom store <- atomically newFileStore storeLog <- liftIO $ mapM (`readWriteFileStore` store) storeLogFile @@ -112,17 +109,7 @@ newXFTPServerEnv config@XFTPServerConfig {storeLogFile, fileSizeQuota, caCertifi forM_ fileSizeQuota $ \quota -> do logInfo $ "Total / available storage: " <> tshow quota <> " / " <> tshow (quota - used) when (quota < used) $ logInfo "WARNING: storage quota is less than used storage, no files can be uploaded!" - tlsServerParams' <- liftIO $ loadTLSServerParams caCertificateFile certificateFile privateKeyFile - let TransportServerConfig {alpn} = transportConfig config - let tlsServerParams = case alpn of - Nothing -> tlsServerParams' - Just supported -> - tlsServerParams' - { T.serverHooks = - def - { T.onALPNClientSuggest = Just $ pure . fromMaybe "" . find (`elem` supported) - } - } + tlsServerParams <- liftIO $ loadTLSServerParams caCertificateFile certificateFile privateKeyFile (alpn transportConfig) Fingerprint fp <- liftIO $ loadFingerprint caCertificateFile serverStats <- atomically . newFileServerStats =<< liftIO getCurrentTime pure XFTPEnv {config, store, storeLog, random, tlsServerParams, serverIdentity = C.KeyHash fp, serverStats} diff --git a/src/Simplex/Messaging/Agent/Server.hs b/src/Simplex/Messaging/Agent/Server.hs index 368c0a23d..da87fde11 100644 --- a/src/Simplex/Messaging/Agent/Server.hs +++ b/src/Simplex/Messaging/Agent/Server.hs @@ -49,7 +49,7 @@ runSMPAgentBlocking (ATransport t) cfg@AgentConfig {tcpPort, caCertificateFile, smpAgent :: forall c. Transport c => TProxy c -> ServiceName -> Env -> IO () smpAgent _ port env = do -- tlsServerParams is not in Env to avoid breaking functional API w/t key and certificate generation - tlsServerParams <- loadTLSServerParams caCertificateFile certificateFile privateKeyFile + tlsServerParams <- loadTLSServerParams caCertificateFile certificateFile privateKeyFile Nothing clientId <- newTVarIO initClientId runTransportServer started port tlsServerParams defaultTransportServerConfig $ \(h :: c) -> do putLn h $ "Welcome to SMP agent v" <> B.pack simplexMQVersion diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 38c36f9f2..176602f4b 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -260,6 +260,7 @@ data ProtocolClientConfig v = ProtocolClientConfig defaultTransport :: (ServiceName, ATransport), -- | network configuration networkConfig :: NetworkConfig, + clientALPN :: Maybe [ALPN], -- | client-server protocol version range serverVRange :: VersionRange v, -- | agree shared session secret (used in SMP proxy) @@ -267,19 +268,20 @@ data ProtocolClientConfig v = ProtocolClientConfig } -- | Default protocol client configuration. -defaultClientConfig :: VersionRange v -> ProtocolClientConfig v -defaultClientConfig serverVRange = +defaultClientConfig :: Maybe [ALPN] -> VersionRange v -> ProtocolClientConfig v +defaultClientConfig clientALPN serverVRange = ProtocolClientConfig { qSize = 64, defaultTransport = ("443", transport @TLS), networkConfig = defaultNetworkConfig, + clientALPN, serverVRange, agreeSecret = False } {-# INLINE defaultClientConfig #-} defaultSMPClientConfig :: ProtocolClientConfig SMPVersion -defaultSMPClientConfig = defaultClientConfig supportedClientSMPRelayVRange +defaultSMPClientConfig = defaultClientConfig (Just supportedSMPHandshakes) supportedClientSMPRelayVRange {-# INLINE defaultSMPClientConfig #-} data Request err msg = Request @@ -332,7 +334,7 @@ type TransportSession msg = (UserId, ProtoServer msg, Maybe EntityId) -- A single queue can be used for multiple 'SMPClient' instances, -- as 'SMPServerTransmission' includes server information. getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> TransportSession msg -> ProtocolClientConfig v -> Maybe (TBQueue (ServerTransmission v msg)) -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) -getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, serverVRange, agreeSecret} msgQ disconnected = do +getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, clientALPN, serverVRange, agreeSecret} msgQ disconnected = do case chooseTransportHost networkConfig (host srv) of Right useHost -> (getCurrentTime >>= atomically . mkProtocolClient useHost >>= runClient useTransport useHost) @@ -370,7 +372,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize runClient :: (ServiceName, ATransport) -> TransportHost -> PClient v err msg -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) runClient (port', ATransport t) useHost c = do cVar <- newEmptyTMVarIO - let tcConfig = transportClientConfig networkConfig + let tcConfig = (transportClientConfig networkConfig) {alpn = clientALPN} username = proxyUsername transportSession action <- async $ diff --git a/src/Simplex/Messaging/Notifications/Client.hs b/src/Simplex/Messaging/Notifications/Client.hs index 72a92c278..cc698b344 100644 --- a/src/Simplex/Messaging/Notifications/Client.hs +++ b/src/Simplex/Messaging/Notifications/Client.hs @@ -10,7 +10,7 @@ import Data.Word (Word16) import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol -import Simplex.Messaging.Notifications.Transport (NTFVersion, supportedClientNTFVRange) +import Simplex.Messaging.Notifications.Transport (NTFVersion, supportedClientNTFVRange, supportedNTFHandshakes) import Simplex.Messaging.Protocol (ErrorType) import Simplex.Messaging.Util (bshow) @@ -19,7 +19,7 @@ type NtfClient = ProtocolClient NTFVersion ErrorType NtfResponse type NtfClientError = ProtocolClientError ErrorType defaultNTFClientConfig :: ProtocolClientConfig NTFVersion -defaultNTFClientConfig = defaultClientConfig supportedClientNTFVRange +defaultNTFClientConfig = defaultClientConfig (Just supportedNTFHandshakes) supportedClientNTFVRange ntfRegisterToken :: NtfClient -> C.APrivateAuthKey -> NewNtfEntity 'Token -> ExceptT NtfClientError IO (NtfTokenId, C.PublicKeyX25519) ntfRegisterToken c pKey newTkn = diff --git a/src/Simplex/Messaging/Notifications/Server/Env.hs b/src/Simplex/Messaging/Notifications/Server/Env.hs index 5bcd72f3d..5ebd5230e 100644 --- a/src/Simplex/Messaging/Notifications/Server/Env.hs +++ b/src/Simplex/Messaging/Notifications/Server/Env.hs @@ -34,7 +34,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (ATransport, THandleParams, TransportPeer (..)) -import Simplex.Messaging.Transport.Server (TransportServerConfig, loadFingerprint, loadTLSServerParams) +import Simplex.Messaging.Transport.Server (TransportServerConfig, alpn, loadFingerprint, loadTLSServerParams) import System.IO (IOMode (..)) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -84,7 +84,7 @@ data NtfEnv = NtfEnv } newNtfServerEnv :: NtfServerConfig -> IO NtfEnv -newNtfServerEnv config@NtfServerConfig {subQSize, pushQSize, smpAgentCfg, apnsConfig, storeLogFile, caCertificateFile, certificateFile, privateKeyFile} = do +newNtfServerEnv config@NtfServerConfig {subQSize, pushQSize, smpAgentCfg, apnsConfig, storeLogFile, caCertificateFile, certificateFile, privateKeyFile, transportConfig} = do random <- liftIO C.newRandom store <- atomically newNtfStore logInfo "restoring subscriptions..." @@ -92,7 +92,7 @@ newNtfServerEnv config@NtfServerConfig {subQSize, pushQSize, smpAgentCfg, apnsCo logInfo "restored subscriptions" subscriber <- atomically $ newNtfSubscriber subQSize smpAgentCfg random pushServer <- atomically $ newNtfPushServer pushQSize apnsConfig - tlsServerParams <- liftIO $ loadTLSServerParams caCertificateFile certificateFile privateKeyFile + tlsServerParams <- liftIO $ loadTLSServerParams caCertificateFile certificateFile privateKeyFile (alpn transportConfig) Fingerprint fp <- liftIO $ loadFingerprint caCertificateFile serverStats <- atomically . newNtfServerStats =<< liftIO getCurrentTime pure NtfEnv {config, subscriber, pushServer, store, storeLog, random, tlsServerParams, serverIdentity = C.KeyHash fp, serverStats} diff --git a/src/Simplex/Messaging/Notifications/Server/Main.hs b/src/Simplex/Messaging/Notifications/Server/Main.hs index a8d16d85d..0efb7d599 100644 --- a/src/Simplex/Messaging/Notifications/Server/Main.hs +++ b/src/Simplex/Messaging/Notifications/Server/Main.hs @@ -18,7 +18,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Server (runNtfServer) import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..), defaultInactiveClientExpiration) import Simplex.Messaging.Notifications.Server.Push.APNS (defaultAPNSPushClientConfig) -import Simplex.Messaging.Notifications.Transport (supportedServerNTFVRange) +import Simplex.Messaging.Notifications.Transport (supportedNTFHandshakes, supportedServerNTFVRange) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), pattern NtfServer) import Simplex.Messaging.Server.CLI import Simplex.Messaging.Server.Expiration @@ -133,7 +133,8 @@ ntfServerCLI cfgPath logPath = ntfServerVRange = supportedServerNTFVRange, transportConfig = defaultTransportServerConfig - { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini + { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini, + alpn = Just supportedNTFHandshakes } } diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index e2c287437..342b42fc4 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -52,9 +52,15 @@ currentServerNTFVersion = VersionNTF 2 supportedClientNTFVRange :: VersionRangeNTF supportedClientNTFVRange = mkVersionRange initialNTFVersion currentClientNTFVersion +legacyServerNTFVRange :: VersionRangeNTF +legacyServerNTFVRange = mkVersionRange initialNTFVersion initialNTFVersion + supportedServerNTFVRange :: VersionRangeNTF supportedServerNTFVRange = mkVersionRange initialNTFVersion currentServerNTFVersion +supportedNTFHandshakes :: [ALPN] +supportedNTFHandshakes = ["ntf/1"] + type THandleNTF c p = THandle NTFVersion c p data NtfServerHandshake = NtfServerHandshake @@ -104,12 +110,13 @@ ntfServerHandshake :: forall c. Transport c => C.APrivateSignKey -> c -> C.KeyPa ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do let th@THandle {params = THandleParams {sessionId}} = ntfTHandle c let sk = C.signX509 serverSignKey $ C.publicToX509 k - sendHandshake th $ NtfServerHandshake {sessionId, ntfVersionRange = ntfVRange, authPubKey = Just sk} + let ntfVersionRange = maybe legacyServerNTFVRange (const ntfVRange) $ getSessionALPN c + sendHandshake th $ NtfServerHandshake {sessionId, ntfVersionRange, authPubKey = Just sk} getHandshake th >>= \case NtfClientHandshake {ntfVersion = v, keyHash} | keyHash /= kh -> throwError $ TEHandshake IDENTITY - | v `isCompatible` ntfVRange -> + | v `isCompatible` ntfVersionRange -> pure $ ntfThHandleServer th v pk | otherwise -> throwError $ TEHandshake VERSION diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index baadfc79b..6794ad979 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -33,8 +33,8 @@ import Simplex.Messaging.Server.Stats import Simplex.Messaging.Server.StoreLog import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (ATransport, VersionSMP, VersionRangeSMP) -import Simplex.Messaging.Transport.Server (SocketState, TransportServerConfig, loadFingerprint, loadTLSServerParams, newSocketState) +import Simplex.Messaging.Transport (ATransport, VersionRangeSMP, VersionSMP) +import Simplex.Messaging.Transport.Server (SocketState, TransportServerConfig, alpn, loadFingerprint, loadTLSServerParams, newSocketState) import System.IO (IOMode (..)) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -174,13 +174,13 @@ newSubscription subThread = do return Sub {subThread, delivered} newEnv :: ServerConfig -> IO Env -newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, storeLogFile} = do +newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, storeLogFile, transportConfig} = do server <- atomically newServer queueStore <- atomically newQueueStore msgStore <- atomically newMsgStore random <- liftIO C.newRandom storeLog <- restoreQueues queueStore `mapM` storeLogFile - tlsServerParams <- loadTLSServerParams caCertificateFile certificateFile privateKeyFile + tlsServerParams <- loadTLSServerParams caCertificateFile certificateFile privateKeyFile (alpn transportConfig) Fingerprint fp <- loadFingerprint caCertificateFile let serverIdentity = KeyHash fp serverStats <- atomically . newServerStats =<< getCurrentTime diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index a7844cc95..d75d02812 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -25,7 +25,7 @@ import Simplex.Messaging.Server (runSMPServer) import Simplex.Messaging.Server.CLI import Simplex.Messaging.Server.Env.STM (ServerConfig (..), defMsgExpirationDays, defaultInactiveClientExpiration, defaultMessageExpiration) import Simplex.Messaging.Server.Expiration -import Simplex.Messaging.Transport (simplexMQVersion, supportedServerSMPRelayVRange) +import Simplex.Messaging.Transport (simplexMQVersion, supportedSMPHandshakes, supportedServerSMPRelayVRange) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig (..), defaultTransportServerConfig) import Simplex.Messaging.Util (safeDecodeUtf8) @@ -211,7 +211,8 @@ smpServerCLI cfgPath logPath = smpServerVRange = supportedServerSMPRelayVRange, transportConfig = defaultTransportServerConfig - { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini + { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini, + alpn = Just supportedSMPHandshakes }, controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini } diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 8dfd15813..4b5098c39 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -33,9 +33,12 @@ module Simplex.Messaging.Transport VersionSMP, VersionRangeSMP, THandleSMP, + supportedSMPHandshakes, supportedClientSMPRelayVRange, supportedServerSMPRelayVRange, + legacyServerSMPRelayVRange, currentClientSMPRelayVersion, + legacyServerSMPRelayVersion, currentServerSMPRelayVersion, batchCmdsSMPVersion, basicAuthSMPVersion, @@ -152,6 +155,9 @@ authCmdsSMPVersion = VersionSMP 7 currentClientSMPRelayVersion :: VersionSMP currentClientSMPRelayVersion = VersionSMP 6 +legacyServerSMPRelayVersion :: VersionSMP +legacyServerSMPRelayVersion = VersionSMP 6 + currentServerSMPRelayVersion :: VersionSMP currentServerSMPRelayVersion = VersionSMP 7 @@ -160,9 +166,15 @@ currentServerSMPRelayVersion = VersionSMP 7 supportedClientSMPRelayVRange :: VersionRangeSMP supportedClientSMPRelayVRange = mkVersionRange batchCmdsSMPVersion currentClientSMPRelayVersion +legacyServerSMPRelayVRange :: VersionRangeSMP +legacyServerSMPRelayVRange = mkVersionRange batchCmdsSMPVersion legacyServerSMPRelayVersion + supportedServerSMPRelayVRange :: VersionRangeSMP supportedServerSMPRelayVRange = mkVersionRange batchCmdsSMPVersion currentServerSMPRelayVersion +supportedSMPHandshakes :: [ALPN] +supportedSMPHandshakes = ["smp/1"] + simplexMQVersion :: String simplexMQVersion = showVersion SMQ.version @@ -194,6 +206,9 @@ class Transport c where -- | tls-unique channel binding per RFC5929 tlsUnique :: c -> SessionId + -- | ALPN value negotiated for the session + getSessionALPN :: c -> Maybe ALPN + -- | Close connection closeConnection :: c -> IO () @@ -288,6 +303,7 @@ instance Transport TLS where getServerConnection = getTLS TServer getClientConnection = getTLS TClient getServerCerts = tlsServerCerts + getSessionALPN = tlsALPN tlsUnique = tlsUniq closeConnection tls = closeTLS $ tlsContext tls @@ -468,12 +484,13 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do let th@THandle {params = THandleParams {sessionId}} = smpTHandle c sk = C.signX509 serverSignKey $ C.publicToX509 k certChain = getServerCerts c - sendHandshake th $ ServerHandshake {sessionId, smpVersionRange = smpVRange, authPubKey = Just (certChain, sk)} + smpVersionRange = maybe legacyServerSMPRelayVRange (const smpVRange) $ getSessionALPN c + sendHandshake th $ ServerHandshake {sessionId, smpVersionRange, authPubKey = Just (certChain, sk)} getHandshake th >>= \case ClientHandshake {smpVersion = v, keyHash, authPubKey = k'} | keyHash /= kh -> throwE $ TEHandshake IDENTITY - | v `isCompatible` smpVRange -> + | v `isCompatible` smpVersionRange -> pure $ smpThHandleServer th v pk k' | otherwise -> throwE $ TEHandshake VERSION diff --git a/src/Simplex/Messaging/Transport/HTTP2/Server.hs b/src/Simplex/Messaging/Transport/HTTP2/Server.hs index c75d8fa31..f8ea1bd1d 100644 --- a/src/Simplex/Messaging/Transport/HTTP2/Server.hs +++ b/src/Simplex/Messaging/Transport/HTTP2/Server.hs @@ -51,7 +51,7 @@ data HTTP2Server = HTTP2Server -- This server is for testing only, it processes all requests in a single queue. getHTTP2Server :: HTTP2ServerConfig -> IO HTTP2Server getHTTP2Server HTTP2ServerConfig {qSize, http2Port, bufferSize, bodyHeadSize, serverSupported, caCertificateFile, certificateFile, privateKeyFile, transportConfig} = do - tlsServerParams <- loadSupportedTLSServerParams serverSupported caCertificateFile certificateFile privateKeyFile + tlsServerParams <- loadSupportedTLSServerParams serverSupported caCertificateFile certificateFile privateKeyFile (alpn transportConfig) started <- newEmptyTMVarIO reqQ <- newTBQueueIO qSize action <- async $ diff --git a/src/Simplex/Messaging/Transport/Server.hs b/src/Simplex/Messaging/Transport/Server.hs index e7360b21b..145b438e0 100644 --- a/src/Simplex/Messaging/Transport/Server.hs +++ b/src/Simplex/Messaging/Transport/Server.hs @@ -28,10 +28,10 @@ import Control.Logger.Simple import Control.Monad import qualified Crypto.Store.X509 as SX import Data.Default (def) -import Data.List (find) import Data.IntMap.Strict (IntMap) import qualified Data.IntMap.Strict as IM -import Data.Maybe (fromJust) +import Data.List (find) +import Data.Maybe (fromJust, fromMaybe) import qualified Data.X509 as X import Data.X509.Validation (Fingerprint (..)) import qualified Data.X509.Validation as XV @@ -152,12 +152,13 @@ startTCPServer started port = withSocketsDo $ resolve >>= open >>= setStarted pure sock setStarted sock = atomically (tryPutTMVar started True) >> pure sock -loadTLSServerParams :: FilePath -> FilePath -> FilePath -> IO T.ServerParams +loadTLSServerParams :: FilePath -> FilePath -> FilePath -> Maybe [ALPN] -> IO T.ServerParams loadTLSServerParams = loadSupportedTLSServerParams supportedParameters -loadSupportedTLSServerParams :: T.Supported -> FilePath -> FilePath -> FilePath -> IO T.ServerParams -loadSupportedTLSServerParams serverSupported caCertificateFile certificateFile privateKeyFile = - fromCredential <$> loadServerCredential +loadSupportedTLSServerParams :: T.Supported -> FilePath -> FilePath -> FilePath -> Maybe [ALPN] -> IO T.ServerParams +loadSupportedTLSServerParams serverSupported caCertificateFile certificateFile privateKeyFile alpn_ = do + tlsServerParams <- fromCredential <$> loadServerCredential + pure tlsServerParams {T.serverHooks = maybe def alpnHooks alpn_} where loadServerCredential :: IO T.Credential loadServerCredential = @@ -172,6 +173,7 @@ loadSupportedTLSServerParams serverSupported caCertificateFile certificateFile p T.serverHooks = def, T.serverSupported = serverSupported } + alpnHooks supported = def {T.onALPNClientSuggest = Just $ pure . fromMaybe "" . find (`elem` supported)} loadFingerprint :: FilePath -> IO Fingerprint loadFingerprint certificateFile = do diff --git a/src/Simplex/Messaging/Transport/WebSockets.hs b/src/Simplex/Messaging/Transport/WebSockets.hs index 062f4f0f0..0883fcc28 100644 --- a/src/Simplex/Messaging/Transport/WebSockets.hs +++ b/src/Simplex/Messaging/Transport/WebSockets.hs @@ -14,7 +14,8 @@ import Network.WebSockets import Network.WebSockets.Stream (Stream) import qualified Network.WebSockets.Stream as S import Simplex.Messaging.Transport - ( TProxy, + ( ALPN, + TProxy, Transport (..), TransportConfig (..), TransportError (..), @@ -28,6 +29,7 @@ import Simplex.Messaging.Transport.Buffer (trimCR) data WS = WS { wsPeer :: TransportPeer, tlsUniq :: ByteString, + wsALPN :: Maybe ALPN, wsStream :: Stream, wsConnection :: Connection, wsTransportConfig :: TransportConfig, @@ -61,6 +63,9 @@ instance Transport WS where getServerCerts :: WS -> X.CertificateChain getServerCerts = wsServerCerts + getSessionALPN :: WS -> Maybe ALPN + getSessionALPN = wsALPN + tlsUnique :: WS -> ByteString tlsUnique = tlsUniq @@ -90,7 +95,8 @@ getWS wsPeer cfg wsServerCerts cxt = withTlsUnique wsPeer cxt connectWS connectWS tlsUniq = do s <- makeTLSContextStream cxt wsConnection <- connectPeer wsPeer s - pure $ WS {wsPeer, tlsUniq, wsStream = s, wsConnection, wsTransportConfig = cfg, wsServerCerts} + wsALPN <- T.getNegotiatedProtocol cxt + pure $ WS {wsPeer, tlsUniq, wsALPN, wsStream = s, wsConnection, wsTransportConfig = cfg, wsServerCerts} connectPeer :: TransportPeer -> Stream -> IO Connection connectPeer TServer = acceptClientRequest connectPeer TClient = sendClientRequest diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index d3e4e6924..cdcf5baed 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -80,7 +80,7 @@ import qualified Simplex.Messaging.Agent.Protocol as A import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') -import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), TransportSessionMode (TSMEntity, TSMUser), defaultSMPClientConfig) +import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), TransportSessionMode (TSMEntity, TSMUser), defaultClientConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR @@ -90,7 +90,7 @@ import Simplex.Messaging.Protocol (BasicAuth, ErrorType (..), MsgBody, ProtocolS import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) import Simplex.Messaging.Server.Expiration -import Simplex.Messaging.Transport (ATransport (..), SMPVersion, VersionSMP, authCmdsSMPVersion, basicAuthSMPVersion, batchCmdsSMPVersion, currentServerSMPRelayVersion) +import Simplex.Messaging.Transport (ATransport (..), SMPVersion, VersionSMP, authCmdsSMPVersion, basicAuthSMPVersion, batchCmdsSMPVersion, currentServerSMPRelayVersion, supportedSMPHandshakes) import Simplex.Messaging.Util (diffToMicroseconds) import Simplex.Messaging.Version (VersionRange (..)) import qualified Simplex.Messaging.Version as V @@ -175,13 +175,16 @@ pattern Rcvd :: AgentMsgId -> ACommand 'Agent 'AEConn pattern Rcvd agentMsgId <- RCVD MsgMeta {integrity = MsgOk} [MsgReceipt {agentMsgId, msgRcptStatus = MROk}] smpCfgVPrev :: ProtocolClientConfig SMPVersion -smpCfgVPrev = (smpCfg agentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg agentCfg} +smpCfgVPrev = (smpCfg agentCfg) {clientALPN = Nothing, serverVRange = prevRange $ serverVRange $ smpCfg agentCfg} smpCfgV7 :: ProtocolClientConfig SMPVersion smpCfgV7 = (smpCfg agentCfg) {serverVRange = V.mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} +ntfCfgVPrev :: ProtocolClientConfig NTFVersion +ntfCfgVPrev = (ntfCfg agentCfg) {clientALPN = Nothing, serverVRange = V.mkVersionRange (VersionNTF 1) (VersionNTF 1)} + ntfCfgV2 :: ProtocolClientConfig NTFVersion -ntfCfgV2 = (smpCfg agentCfg) {serverVRange = V.mkVersionRange (VersionNTF 1) authBatchCmdsNTFVersion} +ntfCfgV2 = (ntfCfg agentCfg) {serverVRange = V.mkVersionRange (VersionNTF 1) authBatchCmdsNTFVersion} agentCfgVPrev :: AgentConfig agentCfgVPrev = @@ -190,7 +193,8 @@ agentCfgVPrev = smpAgentVRange = prevRange $ smpAgentVRange agentCfg, smpClientVRange = prevRange $ smpClientVRange agentCfg, e2eEncryptVRange = prevRange $ e2eEncryptVRange agentCfg, - smpCfg = smpCfgVPrev + smpCfg = smpCfgVPrev, + ntfCfg = ntfCfgVPrev } -- agent config for the next client version @@ -2416,7 +2420,8 @@ testCreateQueueAuth srvVersion clnt1 clnt2 = do where getClient clientId (clntAuth, clntVersion) db = let servers = initAgentServers {smp = userServers [ProtoServerWithAuth testSMPServer clntAuth]} - smpCfg = (defaultSMPClientConfig :: ProtocolClientConfig SMPVersion) {serverVRange = V.mkVersionRange (prevVersion basicAuthSMPVersion) clntVersion} + alpn_ = if clntVersion >= authCmdsSMPVersion then Just supportedSMPHandshakes else Nothing + smpCfg = defaultClientConfig alpn_ $ V.mkVersionRange (prevVersion basicAuthSMPVersion) clntVersion sndAuthAlg = if srvVersion >= authCmdsSMPVersion && clntVersion >= authCmdsSMPVersion then C.AuthAlg C.SX25519 else C.AuthAlg C.SEd25519 in getSMPAgentClient' clientId agentCfg {smpCfg, sndAuthAlg} servers db diff --git a/tests/NtfClient.hs b/tests/NtfClient.hs index 46a199777..564523e0b 100644 --- a/tests/NtfClient.hs +++ b/tests/NtfClient.hs @@ -36,6 +36,7 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Notifications.Protocol (NtfResponse) import Simplex.Messaging.Notifications.Server (runNtfServerBlocking) import Simplex.Messaging.Notifications.Server.Env +import qualified Simplex.Messaging.Notifications.Server.Env as Env import Simplex.Messaging.Notifications.Server.Push.APNS import Simplex.Messaging.Notifications.Server.Push.APNS.Internal import Simplex.Messaging.Notifications.Transport @@ -45,6 +46,7 @@ import Simplex.Messaging.Transport.Client import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), http2TLSParams) import Simplex.Messaging.Transport.HTTP2.Server import Simplex.Messaging.Transport.Server +import qualified Simplex.Messaging.Transport.Server as Server import Simplex.Messaging.Version (mkVersionRange) import Test.Hspec import UnliftIO.Async @@ -113,7 +115,8 @@ ntfServerCfgV2 :: NtfServerConfig ntfServerCfgV2 = ntfServerCfg { ntfServerVRange = mkVersionRange initialNTFVersion authBatchCmdsNTFVersion, - smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion}} + smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion}}, + Env.transportConfig = defaultTransportServerConfig {Server.alpn = Just supportedNTFHandshakes} } withNtfServerStoreLog :: ATransport -> (ThreadId -> IO a) -> IO a diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index cf222c3b4..ae9baeb3c 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -24,8 +24,10 @@ import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client +import qualified Simplex.Messaging.Transport.Client as Client import Simplex.Messaging.Transport.Server -import Simplex.Messaging.Version (mkVersionRange) +import qualified Simplex.Messaging.Transport.Server as Server +import Simplex.Messaging.Version import System.Environment (lookupEnv) import System.Info (os) import Test.Hspec @@ -73,10 +75,15 @@ testSMPClient = testSMPClientVR supportedClientSMPRelayVRange testSMPClientVR :: Transport c => VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a testSMPClientVR vr client = do Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost - runTransportClient defaultTransportClientConfig Nothing useHost testPort (Just testKeyHash) $ \h -> + let tcConfig = defaultTransportClientConfig {Client.alpn = clientALPN} + runTransportClient tcConfig Nothing useHost testPort (Just testKeyHash) $ \h -> runExceptT (smpClientHandshake h Nothing testKeyHash vr) >>= \case Right th -> client th Left e -> error $ show e + where + clientALPN + | authCmdsSMPVersion `isCompatible` vr = Just supportedSMPHandshakes + | otherwise = Nothing cfg :: ServerConfig cfg = @@ -104,7 +111,7 @@ cfg = privateKeyFile = "tests/fixtures/server.key", certificateFile = "tests/fixtures/server.crt", smpServerVRange = supportedServerSMPRelayVRange, - transportConfig = defaultTransportServerConfig, + transportConfig = defaultTransportServerConfig {Server.alpn = Just supportedSMPHandshakes}, controlPort = Nothing } From b40654c95dd4b00764f60bc3c35bc4e64b550cf1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 8 May 2024 13:05:06 +0100 Subject: [PATCH 039/107] update agent to v7/v2 SMP/NTF protocol versions (#997) * update agent to v7/v2 SMP/NTF protocol versions --- src/Simplex/Messaging/Notifications/Transport.hs | 2 +- src/Simplex/Messaging/Transport.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index 342b42fc4..58391c225 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -44,7 +44,7 @@ authBatchCmdsNTFVersion :: VersionNTF authBatchCmdsNTFVersion = VersionNTF 2 currentClientNTFVersion :: VersionNTF -currentClientNTFVersion = VersionNTF 1 +currentClientNTFVersion = VersionNTF 2 currentServerNTFVersion :: VersionNTF currentServerNTFVersion = VersionNTF 2 diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 4b5098c39..8c06c0d82 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -153,7 +153,7 @@ authCmdsSMPVersion :: VersionSMP authCmdsSMPVersion = VersionSMP 7 currentClientSMPRelayVersion :: VersionSMP -currentClientSMPRelayVersion = VersionSMP 6 +currentClientSMPRelayVersion = VersionSMP 7 legacyServerSMPRelayVersion :: VersionSMP legacyServerSMPRelayVersion = VersionSMP 6 From 3f57d54832de351249367212cd4a53fd260b4633 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 8 May 2024 16:57:04 +0400 Subject: [PATCH 040/107] xftp: catch exceptions in chunk download (#1133) --- src/Simplex/FileTransfer/Client.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 6cae2dd59..468df6157 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -245,8 +245,13 @@ downloadXFTPChunk g c@XFTPClient {config} rpKey fId chunkSpec@XFTPRcvChunkSpec { let dhSecret = C.dh' sDhKey rpDhKey cbState <- liftEither . first PCECryptoError $ LC.cbInit dhSecret cbNonce let t = chunkTimeout config chunkSize - ExceptT (sequence <$> (t `timeout` download cbState)) >>= maybe (throwError PCEResponseTimeout) pure + ExceptT (sequence <$> (t `timeout` (download cbState `catches` errors))) >>= maybe (throwError PCEResponseTimeout) pure where + errors = + [ Handler $ \(_e :: H.HTTP2Error) -> pure $ Left PCENetworkError, + Handler $ \(e :: IOException) -> pure $ Left (PCEIOError e), + Handler $ \(_e :: SomeException) -> pure $ Left PCENetworkError + ] download cbState = runExceptT . withExceptT PCEResponseError $ receiveEncFile chunkPart cbState chunkSpec `catchError` \e -> From ea21b296fdbb7aa87d5d37f1181469492ec2daef Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 8 May 2024 15:33:51 +0100 Subject: [PATCH 041/107] agent: reset error count and do not report errors when consequitive timeouts happen while offline (#1136) * agent: reset error count and do not report errors when consequitive timeouts happen while offline * refactor * comment --- src/Simplex/Messaging/Agent/Client.hs | 16 ++++++++++------ src/Simplex/Messaging/Agent/Env/SQLite.hs | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 27223b12f..59170ce2d 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -630,12 +630,16 @@ reconnectSMPClient tc c tSess@(_, srv, _) qs = do let t = (length qs `div` 90 + 1) * tcpTimeout * 3 ExceptT (sequence <$> (t `timeout` runExceptT resubscribe)) >>= \case Just _ -> atomically $ writeTVar tc 0 - Nothing -> do - tc' <- atomically $ stateTVar tc $ \i -> (i + 1, i + 1) - maxTC <- asks $ maxSubscriptionTimeouts . config - let err = if tc' >= maxTC then CRITICAL True else INTERNAL - msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess - atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ err msg) + Nothing -> + (offline <$> readTVarIO (userNetworkState c)) >>= \case + -- reset and do not report consequitive timeouts while offline + Just _ -> atomically $ writeTVar tc 0 + Nothing -> do + tc' <- atomically $ stateTVar tc $ \i -> (i + 1, i + 1) + maxTC <- asks $ maxSubscriptionTimeouts . config + let err = if tc' >= maxTC then CRITICAL True else INTERNAL + msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess + atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ err msg) where resubscribe :: AM () resubscribe = do diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index f91144fdc..07d3f29a8 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -181,7 +181,7 @@ defaultAgentConfig = maxWorkerRestartsPerMin = 5, -- 3 consecutive subscription timeouts will result in alert to the user -- this is a fallback, as the timeout set to 3x of expected timeout, to avoid potential locking. - maxSubscriptionTimeouts = 3, + maxSubscriptionTimeouts = 5, storedMsgDataTTL = 21 * nominalDay, rcvFilesTTL = 2 * nominalDay, sndFilesTTL = nominalDay, From b27f126bab3d47212fd1f3a713aac3a6e3ed3062 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 8 May 2024 23:00:00 +0100 Subject: [PATCH 042/107] include server version range in transport handle (#1135) * include server version range in transport handle * xftp handshake * remove coment * simplify * comments --- src/Simplex/FileTransfer/Client.hs | 21 +++++---- src/Simplex/FileTransfer/Server.hs | 31 +++++++------ src/Simplex/FileTransfer/Server/Env.hs | 3 ++ src/Simplex/FileTransfer/Server/Main.hs | 2 + src/Simplex/Messaging/Agent/Protocol.hs | 1 + src/Simplex/Messaging/Client.hs | 1 + src/Simplex/Messaging/Crypto/Ratchet.hs | 31 +++++++------ .../Messaging/Notifications/Transport.hs | 44 +++++++++++------- src/Simplex/Messaging/Transport.hs | 46 ++++++++++++------- src/Simplex/Messaging/Version.hs | 23 ++++++++++ tests/CoreTests/BatchingTests.hs | 1 + tests/CoreTests/VersionRangeTests.hs | 36 +++++++++++++++ tests/XFTPClient.hs | 2 + 13 files changed, 173 insertions(+), 69 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 4efff9388..90067e22d 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -57,7 +57,7 @@ import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client import Simplex.Messaging.Transport.HTTP2.File import Simplex.Messaging.Util (bshow, liftEitherWith, liftError', tshow, whenM) -import Simplex.Messaging.Version (compatibleVersion, pattern Compatible) +import Simplex.Messaging.Version import UnliftIO import UnliftIO.Directory @@ -109,7 +109,9 @@ getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, clientDisconnected = readTVarIO clientVar >>= mapM_ disconnected http2Client <- liftError' xftpClientError $ getVerifiedHTTP2Client (Just username) useHost usePort (Just keyHash) Nothing http2Config clientDisconnected let HTTP2Client {sessionId, sessionALPN} = http2Client - thParams0 = THandleParams {sessionId, blockSize = xftpBlockSize, thVersion = VersionXFTP 1, thAuth = Nothing, implySessId = False, batch = True} + v = VersionXFTP 1 + thServerVRange = versionToRange v + thParams0 = THandleParams {sessionId, blockSize = xftpBlockSize, thVersion = v, thServerVRange, thAuth = Nothing, implySessId = False, batch = True} logDebug $ "Client negotiated handshake protocol: " <> tshow sessionALPN thParams@THandleParams {thVersion} <- case sessionALPN of Just "xftp/1" -> xftpClientHandshakeV1 serverVRange keyHash http2Client thParams0 @@ -123,9 +125,10 @@ getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpClientHandshakeV1 :: VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP 'TClient -> ExceptT XFTPClientError IO (THandleParamsXFTP 'TClient) xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessionId, serverKey} thParams0 = do shs@XFTPServerHandshake {authPubKey = ck} <- getServerHandshake - (v, sk) <- processServerHandshake shs + (vr, sk) <- processServerHandshake shs + let v = maxVersion vr sendClientHandshake XFTPClientHandshake {xftpVersion = v, keyHash} - pure thParams0 {thAuth = Just THAuthClient {serverPeerPubKey = sk, serverCertKey = ck, sessSecret = Nothing}, thVersion = v} + pure thParams0 {thAuth = Just THAuthClient {serverPeerPubKey = sk, serverCertKey = ck, sessSecret = Nothing}, thVersion = v, thServerVRange = vr} where getServerHandshake :: ExceptT XFTPClientError IO XFTPServerHandshake getServerHandshake = do @@ -133,13 +136,13 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session HTTP2Response {respBody = HTTP2Body {bodyHead = shsBody}} <- liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c helloReq Nothing liftHS . smpDecode =<< liftHS (C.unPad shsBody) - processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionXFTP, C.PublicKeyX25519) + processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionRangeXFTP, C.PublicKeyX25519) processServerHandshake XFTPServerHandshake {xftpVersionRange, sessionId = serverSessId, authPubKey = serverAuth} = do unless (sessionId == serverSessId) $ throwError $ PCEResponseError SESSION - case xftpVersionRange `compatibleVersion` serverVRange of - Nothing -> throwError $ PCEResponseError HANDSHAKE - Just (Compatible v) -> - fmap (v,) . liftHS $ do + case xftpVersionRange `compatibleVRange` serverVRange of + Nothing -> throwError $ PCETransportError TEVersion + Just (Compatible vr) -> + fmap (vr,) . liftHS $ do let (X.CertificateChain cert, exact) = serverAuth case cert of [_leaf, ca] | XV.Fingerprint kh == XV.getFingerprint ca X.HashSHA256 -> pure () diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index 7b6787a43..41c652ea2 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -63,7 +63,7 @@ import Simplex.Messaging.Transport.HTTP2.File (fileBlockSize) import Simplex.Messaging.Transport.HTTP2.Server import Simplex.Messaging.Transport.Server (runTCPServer, tlsServerCredentials) import Simplex.Messaging.Util -import Simplex.Messaging.Version (isCompatible) +import Simplex.Messaging.Version import System.Exit (exitFailure) import System.FilePath (()) import System.IO (hPrint, hPutStrLn, universalNewlineMode) @@ -91,10 +91,10 @@ runXFTPServerBlocking started cfg = newXFTPServerEnv cfg >>= runReaderT (xftpSer data Handshake = HandshakeSent C.PrivateKeyX25519 - | HandshakeAccepted (THandleAuth 'TServer) VersionXFTP + | HandshakeAccepted (THandleParams XFTPVersion 'TServer) xftpServer :: XFTPServerConfig -> TMVar Bool -> M () -xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration} started = do +xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration, xftpServerVRange} started = do mapM_ (expireServerFiles Nothing) fileExpiration restoreServerStats raceAny_ (runServer : expireFilesThread_ cfg <> serverStatsThread_ cfg <> controlPortThread_ cfg) `finally` stopServer @@ -111,7 +111,9 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira let cleanup sessionId = atomically $ TM.delete sessionId sessions liftIO . runHTTP2Server started xftpPort defaultHTTP2BufferSize serverParams transportConfig inactiveClientExpiration cleanup $ \sessionId sessionALPN r sendResponse -> do reqBody <- getHTTP2Body r xftpBlockSize - let thParams0 = THandleParams {sessionId, blockSize = xftpBlockSize, thVersion = VersionXFTP 1, thAuth = Nothing, implySessId = False, batch = True} + let v = VersionXFTP 1 + thServerVRange = versionToRange v + thParams0 = THandleParams {sessionId, blockSize = xftpBlockSize, thVersion = v, thServerVRange, thAuth = Nothing, implySessId = False, batch = True} req0 = XFTPTransportRequest {thParams = thParams0, request = r, reqBody, sendResponse} flip runReaderT env $ case sessionALPN of Nothing -> processRequest req0 @@ -121,12 +123,12 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira Just thParams -> processRequest req0 {thParams} -- proceed with new version (XXX: may as well switch the request handler here) _ -> liftIO . sendResponse $ H.responseNoBody N.ok200 [] -- shouldn't happen: means server picked handshake protocol it doesn't know about xftpServerHandshakeV1 :: X.CertificateChain -> C.APrivateSignKey -> TMap SessionId Handshake -> XFTPTransportRequest -> M (Maybe (THandleParams XFTPVersion 'TServer)) - xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse} = do + xftpServerHandshakeV1 chain serverSignKey sessions XFTPTransportRequest {thParams = thParams0@THandleParams {sessionId}, reqBody = HTTP2Body {bodyHead}, sendResponse} = do s <- atomically $ TM.lookup sessionId sessions r <- runExceptT $ case s of Nothing -> processHello Just (HandshakeSent pk) -> processClientHandshake pk - Just (HandshakeAccepted auth v) -> pure $ Just thParams {thAuth = Just auth, thVersion = v} + Just (HandshakeAccepted thParams) -> pure $ Just thParams either sendError pure r where processHello = do @@ -134,21 +136,24 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira (k, pk) <- atomically . C.generateKeyPair =<< asks random atomically $ TM.insert sessionId (HandshakeSent pk) sessions let authPubKey = (chain, C.signX509 serverSignKey $ C.publicToX509 k) - let hs = XFTPServerHandshake {xftpVersionRange = supportedFileServerVRange, sessionId, authPubKey} + let hs = XFTPServerHandshake {xftpVersionRange = xftpServerVRange, sessionId, authPubKey} shs <- encodeXftp hs liftIO . sendResponse $ H.responseBuilder N.ok200 [] shs pure Nothing processClientHandshake pk = do unless (B.length bodyHead == xftpBlockSize) $ throwError HANDSHAKE body <- liftHS $ C.unPad bodyHead - XFTPClientHandshake {xftpVersion, keyHash} <- liftHS $ smpDecode body + XFTPClientHandshake {xftpVersion = v, keyHash} <- liftHS $ smpDecode body kh <- asks serverIdentity unless (keyHash == kh) $ throwError HANDSHAKE - unless (xftpVersion `isCompatible` supportedFileServerVRange) $ throwError HANDSHAKE - let auth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} - atomically $ TM.insert sessionId (HandshakeAccepted auth xftpVersion) sessions - liftIO . sendResponse $ H.responseNoBody N.ok200 [] - pure Nothing + case compatibleVRange' xftpServerVRange v of + Just (Compatible vr) -> do + let auth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} + thParams = thParams0 {thAuth = Just auth, thVersion = v, thServerVRange = vr} + atomically $ TM.insert sessionId (HandshakeAccepted thParams) sessions + liftIO . sendResponse $ H.responseNoBody N.ok200 [] + pure Nothing + Nothing -> throwError HANDSHAKE sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion 'TServer)) sendError err = do runExceptT (encodeXftp err) >>= \case diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index 414bfb4c4..58c1393f3 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -25,6 +25,7 @@ import Simplex.FileTransfer.Protocol (FileCmd, FileInfo (..), XFTPFileId) import Simplex.FileTransfer.Server.Stats import Simplex.FileTransfer.Server.Store import Simplex.FileTransfer.Server.StoreLog +import Simplex.FileTransfer.Transport (VersionRangeXFTP) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (BasicAuth, RcvPublicAuthKey) import Simplex.Messaging.Server.Expiration @@ -61,6 +62,8 @@ data XFTPServerConfig = XFTPServerConfig caCertificateFile :: FilePath, privateKeyFile :: FilePath, certificateFile :: FilePath, + -- | XFTP client-server protocol version range + xftpServerVRange :: VersionRangeXFTP, -- stats config - see SMP server config logStatsInterval :: Maybe Int64, logStatsStartTime :: Int64, diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index d53b3f4fa..b909b1617 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -20,6 +20,7 @@ import Simplex.FileTransfer.Chunks import Simplex.FileTransfer.Description (FileSize (..)) import Simplex.FileTransfer.Server (runXFTPServer) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defFileExpirationHours, defaultFileExpiration, defaultInactiveClientExpiration, supportedXFTPhandshakes) +import Simplex.FileTransfer.Transport (supportedFileServerVRange) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), pattern XFTPServer) @@ -174,6 +175,7 @@ xftpServerCLI cfgPath logPath = do caCertificateFile = c caCrtFile, privateKeyFile = c serverKeyFile, certificateFile = c serverCrtFile, + xftpServerVRange = supportedFileServerVRange, logStatsInterval = logStats $> 86400, -- seconds logStatsStartTime = 0, -- seconds from 00:00 UTC serverStatsLogFile = combine logPath "file-server-stats.daily.log", diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 5cfd7af03..7160863f1 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -1302,6 +1302,7 @@ instance VersionRangeI SMPClientVersion SMPQueueUri where type VersionT SMPClientVersion SMPQueueUri = SMPQueueInfo versionRange = clientVRange toVersionT (SMPQueueUri _vr addr) v = SMPQueueInfo v addr + toVersionRange (SMPQueueUri _vr addr) vr = SMPQueueUri vr addr -- | SMP queue information sent out-of-band. -- diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 6b1beb6a1..59fbd4c12 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -172,6 +172,7 @@ smpClientStub g sessionId thVersion thAuth = do THandleParams { sessionId, thVersion, + thServerVRange = supportedServerSMPRelayVRange, thAuth, blockSize = smpBlockSize, implySessId = thVersion >= authCmdsSMPVersion, diff --git a/src/Simplex/Messaging/Crypto/Ratchet.hs b/src/Simplex/Messaging/Crypto/Ratchet.hs index 6ab84aa30..db13fddc1 100644 --- a/src/Simplex/Messaging/Crypto/Ratchet.hs +++ b/src/Simplex/Messaging/Crypto/Ratchet.hs @@ -117,7 +117,7 @@ import Simplex.Messaging.Crypto.SNTRUP761.Bindings import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, parseE, parseE') -import Simplex.Messaging.Util ((<$?>), ($>>=)) +import Simplex.Messaging.Util (($>>=), (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal import UnliftIO.STM @@ -266,6 +266,7 @@ instance VersionRangeI E2EVersion (E2ERatchetParamsUri s a) where type VersionT E2EVersion (E2ERatchetParamsUri s a) = (E2ERatchetParams s a) versionRange (E2ERatchetParamsUri vr _ _ _) = vr toVersionT (E2ERatchetParamsUri _ k1 k2 kem_) v = E2ERatchetParams v k1 k2 kem_ + toVersionRange (E2ERatchetParamsUri _ k1 k2 kem_) vr = E2ERatchetParamsUri vr k1 k2 kem_ type RcvE2ERatchetParamsUri a = E2ERatchetParamsUri 'RKSProposed a @@ -377,13 +378,15 @@ generateE2EParams g v useKEM_ = do where kemParams :: IO (Maybe (RKEMParams s, PrivRKEMParams s)) kemParams = case useKEM_ of - Just useKem | v >= pqRatchetE2EEncryptVersion -> Just <$> do - ks@(k, _) <- sntrup761Keypair g - case useKem of - ProposeKEM -> pure (RKParamsProposed k, PrivateRKParamsProposed ks) - AcceptKEM k' -> do - (ct, shared) <- sntrup761Enc g k' - pure (RKParamsAccepted ct k, PrivateRKParamsAccepted ct shared ks) + Just useKem + | v >= pqRatchetE2EEncryptVersion -> + Just <$> do + ks@(k, _) <- sntrup761Keypair g + case useKem of + ProposeKEM -> pure (RKParamsProposed k, PrivateRKParamsProposed ks) + AcceptKEM k' -> do + (ct, shared) <- sntrup761Enc g k' + pure (RKParamsAccepted ct k, PrivateRKParamsAccepted ct shared ks) _ -> pure Nothing -- used by party initiating connection, Bob in double-ratchet spec @@ -456,7 +459,7 @@ pqX3dh (sk1, rk1) dh1 dh2 dh3 kemAccepted = pq = maybe "" (\RatchetKEMAccepted {rcPQRss = KEMSharedKey ss} -> BA.convert ss) kemAccepted (hk, nhk, sk) = let salt = B.replicate 64 '\0' - in hkdf3 salt dhs "SimpleXX3DH" + in hkdf3 salt dhs "SimpleXX3DH" type RatchetX448 = Ratchet 'X448 @@ -698,8 +701,8 @@ data EncMessageHeader = EncMessageHeader -- this encoding depends on version in EncMessageHeader because it is "current" ratchet version instance Encoding EncMessageHeader where - smpEncode EncMessageHeader {ehVersion, ehIV, ehAuthTag, ehBody} - = smpEncode (ehVersion, ehIV, ehAuthTag) <> encodeLarge ehVersion ehBody + smpEncode EncMessageHeader {ehVersion, ehIV, ehAuthTag, ehBody} = + smpEncode (ehVersion, ehIV, ehAuthTag) <> encodeLarge ehVersion ehBody smpP = do (ehVersion, ehIV, ehAuthTag) <- smpP ehBody <- largeP @@ -708,8 +711,6 @@ instance Encoding EncMessageHeader where -- the encoder always uses 2-byte lengths for the new version, even for short headers without PQ keys. encodeLarge :: VersionE2E -> ByteString -> ByteString encodeLarge v s - -- the condition for length is not necessary, it's here as a fallback. - -- | v >= pqRatchetE2EEncryptVersion || B.length s > 255 = smpEncode $ Large s | v >= pqRatchetE2EEncryptVersion = smpEncode $ Large s | otherwise = smpEncode s @@ -729,8 +730,8 @@ data EncRatchetMessage = EncRatchetMessage } encodeEncRatchetMessage :: VersionE2E -> EncRatchetMessage -> ByteString -encodeEncRatchetMessage v EncRatchetMessage {emHeader, emBody, emAuthTag} - = encodeLarge v emHeader <> smpEncode (emAuthTag, Tail emBody) +encodeEncRatchetMessage v EncRatchetMessage {emHeader, emBody, emAuthTag} = + encodeLarge v emHeader <> smpEncode (emAuthTag, Tail emBody) encRatchetMessageP :: Parser EncRatchetMessage encRatchetMessageP = do diff --git a/src/Simplex/Messaging/Notifications/Transport.hs b/src/Simplex/Messaging/Notifications/Transport.hs index 7a3efee54..ddb8880b5 100644 --- a/src/Simplex/Messaging/Notifications/Transport.hs +++ b/src/Simplex/Messaging/Notifications/Transport.hs @@ -117,9 +117,10 @@ ntfServerHandshake serverSignKey c (k, pk) kh ntfVRange = do NtfClientHandshake {ntfVersion = v, keyHash} | keyHash /= kh -> throwError $ TEHandshake IDENTITY - | v `isCompatible` ntfVersionRange -> - pure $ ntfThHandleServer th v pk - | otherwise -> throwE TEVersion + | otherwise -> + case compatibleVRange' ntfVersionRange v of + Just (Compatible vr) -> pure $ ntfThHandleServer th v vr pk + Nothing -> throwE TEVersion -- | Notifcations server client transport handshake. ntfClientHandshake :: forall c. Transport c => c -> C.KeyHash -> VersionRangeNTF -> ExceptT TransportError IO (THandleNTF c 'TClient) @@ -128,34 +129,45 @@ ntfClientHandshake c keyHash ntfVRange = do NtfServerHandshake {sessionId = sessId, ntfVersionRange, authPubKey = sk'} <- getHandshake th if sessionId /= sessId then throwError TEBadSession - else case ntfVersionRange `compatibleVersion` ntfVRange of - Just (Compatible v) -> do + else case ntfVersionRange `compatibleVRange` ntfVRange of + Just (Compatible vr) -> do ck_ <- forM sk' $ \signedKey -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do serverKey <- getServerVerifyKey c pubKey <- C.verifyX509 serverKey signedKey (,(getServerCerts c, signedKey)) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) + let v = maxVersion vr sendHandshake th $ NtfClientHandshake {ntfVersion = v, keyHash} - pure $ ntfThHandleClient th v ck_ + pure $ ntfThHandleClient th v vr ck_ Nothing -> throwE TEVersion -ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> C.PrivateKeyX25519 -> THandleNTF c 'TServer -ntfThHandleServer th v pk = +ntfThHandleServer :: forall c. THandleNTF c 'TServer -> VersionNTF -> VersionRangeNTF -> C.PrivateKeyX25519 -> THandleNTF c 'TServer +ntfThHandleServer th v vr pk = let thAuth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} - in ntfThHandle_ th v (Just thAuth) + in ntfThHandle_ th v vr (Just thAuth) -ntfThHandleClient :: forall c. THandleNTF c 'TClient -> VersionNTF -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleNTF c 'TClient -ntfThHandleClient th v ck_ = +ntfThHandleClient :: forall c. THandleNTF c 'TClient -> VersionNTF -> VersionRangeNTF -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleNTF c 'TClient +ntfThHandleClient th v vr ck_ = let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, sessSecret = Nothing}) <$> ck_ - in ntfThHandle_ th v thAuth + in ntfThHandle_ th v vr thAuth -ntfThHandle_ :: forall c p. THandleNTF c p -> VersionNTF -> Maybe (THandleAuth p) -> THandleNTF c p -ntfThHandle_ th@THandle {params} v thAuth = +ntfThHandle_ :: forall c p. THandleNTF c p -> VersionNTF -> VersionRangeNTF -> Maybe (THandleAuth p) -> THandleNTF c p +ntfThHandle_ th@THandle {params} v vr thAuth = -- TODO drop SMP v6: make thAuth non-optional let v3 = v >= authBatchCmdsNTFVersion - params' = params {thVersion = v, thAuth, implySessId = v3, batch = v3} + params' = params {thVersion = v, thServerVRange = vr, thAuth, implySessId = v3, batch = v3} in (th :: THandleNTF c p) {params = params'} ntfTHandle :: Transport c => c -> THandleNTF c p ntfTHandle c = THandle {connection = c, params} where - params = THandleParams {sessionId = tlsUnique c, blockSize = ntfBlockSize, thVersion = VersionNTF 0, thAuth = Nothing, implySessId = False, batch = False} + v = VersionNTF 0 + params = + THandleParams + { sessionId = tlsUnique c, + blockSize = ntfBlockSize, + thVersion = v, + thServerVRange = versionToRange v, + thAuth = Nothing, + implySessId = False, + batch = False + } diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index ffdbf4a20..ad5a29822 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -340,6 +340,8 @@ type THandleSMP c p = THandle SMPVersion c p data THandleParams v p = THandleParams { sessionId :: SessionId, blockSize :: Int, + -- | server protocol version range + thServerVRange :: VersionRange v, -- | agreed server protocol version thVersion :: Version v, -- | peer public key for command authorization and shared secrets for entity ID encryption @@ -493,9 +495,10 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do ClientHandshake {smpVersion = v, keyHash, authPubKey = k'} | keyHash /= kh -> throwE $ TEHandshake IDENTITY - | v `isCompatible` smpVersionRange -> - pure $ smpThHandleServer th v pk k' - | otherwise -> throwE TEVersion + | otherwise -> + case compatibleVRange' smpVersionRange v of + Just (Compatible vr) -> pure $ smpThHandleServer th v vr pk k' + Nothing -> throwE TEVersion -- | Client SMP transport handshake. -- @@ -506,8 +509,8 @@ smpClientHandshake c ks_ keyHash@(C.KeyHash kh) smpVRange = do ServerHandshake {sessionId = sessId, smpVersionRange, authPubKey} <- getHandshake th if sessionId /= sessId then throwE TEBadSession - else case smpVersionRange `compatibleVersion` smpVRange of - Just (Compatible v) -> do + else case smpVersionRange `compatibleVRange` smpVRange of + Just (Compatible vr) -> do ck_ <- forM authPubKey $ \certKey@(X.CertificateChain cert, exact) -> liftEitherWith (const $ TEHandshake BAD_AUTH) $ do case cert of @@ -516,24 +519,25 @@ smpClientHandshake c ks_ keyHash@(C.KeyHash kh) smpVRange = do serverKey <- getServerVerifyKey c pubKey <- C.verifyX509 serverKey exact (,certKey) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) + let v = maxVersion vr sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = fst <$> ks_} - pure $ smpThHandleClient th v (snd <$> ks_) ck_ + pure $ smpThHandleClient th v vr (snd <$> ks_) ck_ Nothing -> throwE TEVersion -smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer -smpThHandleServer th v pk k_ = +smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> VersionRangeSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer +smpThHandleServer th v vr pk k_ = let thAuth = THAuthServer {serverPrivKey = pk, sessSecret' = (`C.dh'` pk) <$> k_} - in smpThHandle_ th v (Just thAuth) + in smpThHandle_ th v vr (Just thAuth) -smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> Maybe C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient -smpThHandleClient th v pk_ ck_ = +smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> VersionRangeSMP -> Maybe C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient +smpThHandleClient th v vr pk_ ck_ = let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, sessSecret = C.dh' k <$> pk_}) <$> ck_ - in smpThHandle_ th v thAuth + in smpThHandle_ th v vr thAuth -smpThHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> Maybe (THandleAuth p) -> THandleSMP c p -smpThHandle_ th@THandle {params} v thAuth = +smpThHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> VersionRangeSMP -> Maybe (THandleAuth p) -> THandleSMP c p +smpThHandle_ th@THandle {params} v vr thAuth = -- TODO drop SMP v6: make thAuth non-optional - let params' = params {thVersion = v, thAuth, implySessId = v >= authCmdsSMPVersion} + let params' = params {thVersion = v, thServerVRange = vr, thAuth, implySessId = v >= authCmdsSMPVersion} in (th :: THandleSMP c p) {params = params'} sendHandshake :: (Transport c, Encoding smp) => THandle v c p -> smp -> ExceptT TransportError IO () @@ -546,7 +550,17 @@ getHandshake th = ExceptT $ (first (\_ -> TEHandshake PARSE) . A.parseOnly smpP smpTHandle :: Transport c => c -> THandleSMP c p smpTHandle c = THandle {connection = c, params} where - params = THandleParams {sessionId = tlsUnique c, blockSize = smpBlockSize, thVersion = VersionSMP 0, thAuth = Nothing, implySessId = False, batch = True} + v = VersionSMP 0 + params = + THandleParams + { sessionId = tlsUnique c, + blockSize = smpBlockSize, + thServerVRange = versionToRange v, + thVersion = v, + thAuth = Nothing, + implySessId = False, + batch = True + } $(J.deriveJSON (sumTypeJSON id) ''HandshakeError) diff --git a/src/Simplex/Messaging/Version.hs b/src/Simplex/Messaging/Version.hs index 25f7368d1..5576cfa9f 100644 --- a/src/Simplex/Messaging/Version.hs +++ b/src/Simplex/Messaging/Version.hs @@ -23,6 +23,8 @@ module Simplex.Messaging.Version isCompatibleRange, proveCompatible, compatibleVersion, + compatibleVRange, + compatibleVRange', ) where @@ -98,6 +100,7 @@ class VersionScope v => VersionI v a | a -> v where class VersionScope v => VersionRangeI v a | a -> v where type VersionT v a versionRange :: a -> VersionRange v + toVersionRange :: a -> VersionRange v -> a toVersionT :: a -> Version v -> VersionT v a instance VersionScope v => VersionI v (Version v) where @@ -108,6 +111,7 @@ instance VersionScope v => VersionI v (Version v) where instance VersionScope v => VersionRangeI v (VersionRange v) where type VersionT v (VersionRange v) = Version v versionRange = id + toVersionRange _ vr = vr toVersionT _ v = v newtype Compatible a = Compatible_ a @@ -135,5 +139,24 @@ compatibleVersion x vr = max1 = maxVersion $ versionRange x max2 = maxVersion vr +-- | intersection of version ranges +compatibleVRange :: VersionRangeI v a => a -> VersionRange v -> Maybe (Compatible a) +compatibleVRange x vr = + compatibleVRange_ x (max min1 min2) (min max1 max2) + where + VRange min1 max1 = versionRange x + VRange min2 max2 = vr + +-- | version range capped by compatible version +compatibleVRange' :: VersionRangeI v a => a -> Version v -> Maybe (Compatible a) +compatibleVRange' x v + | v <= max1 = compatibleVRange_ x min1 v + | otherwise = Nothing + where + VRange min1 max1 = versionRange x + +compatibleVRange_ :: VersionRangeI v a => a -> Version v -> Version v -> Maybe (Compatible a) +compatibleVRange_ x v1 v2 = Compatible_ . toVersionRange x <$> safeVersionRange v1 v2 + mkCompatibleIf :: a -> Bool -> Maybe (Compatible a) x `mkCompatibleIf` cond = if cond then Just $ Compatible_ x else Nothing diff --git a/tests/CoreTests/BatchingTests.hs b/tests/CoreTests/BatchingTests.hs index 6350baa91..caab0637a 100644 --- a/tests/CoreTests/BatchingTests.hs +++ b/tests/CoreTests/BatchingTests.hs @@ -325,6 +325,7 @@ testTHandleParams v sessionId = { sessionId, blockSize = smpBlockSize, thVersion = v, + thServerVRange = supportedServerSMPRelayVRange, thAuth = Nothing, implySessId = v >= authCmdsSMPVersion, batch = True diff --git a/tests/CoreTests/VersionRangeTests.hs b/tests/CoreTests/VersionRangeTests.hs index cef556376..ff53cc6ca 100644 --- a/tests/CoreTests/VersionRangeTests.hs +++ b/tests/CoreTests/VersionRangeTests.hs @@ -6,6 +6,7 @@ module CoreTests.VersionRangeTests where +import Data.Word (Word16) import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import Simplex.Messaging.Version @@ -38,6 +39,28 @@ versionRangeTests = modifyMaxSuccess (const 1000) $ do (vr 1 3, vr 2 3) `compatible` Just (Version 3) (vr 1 3, vr 2 4) `compatible` Just (Version 3) (vr 1 2, vr 3 4) `compatible` Nothing + it "should choose mutually compatible version range (range intersection)" $ do + (vr 1 1, vr 1 1) `compatibleVR` Just (vr 1 1) + (vr 1 1, vr 1 2) `compatibleVR` Just (vr 1 1) + (vr 1 2, vr 1 2) `compatibleVR` Just (vr 1 2) + (vr 1 2, vr 2 3) `compatibleVR` Just (vr 2 2) + (vr 1 3, vr 2 3) `compatibleVR` Just (vr 2 3) + (vr 1 3, vr 2 4) `compatibleVR` Just (vr 2 3) + (vr 1 2, vr 3 4) `compatibleVR` Nothing + it "should choose compatible version range with changed max version (capped range)" $ do + (vr 1 1, 1) `compatibleVR'` Just (vr 1 1) + (vr 1 1, 2) `compatibleVR'` Nothing + (vr 1 2, 2) `compatibleVR'` Just (vr 1 2) + (vr 1 2, 3) `compatibleVR'` Nothing + (vr 1 3, 2) `compatibleVR'` Just (vr 1 2) + (vr 1 3, 3) `compatibleVR'` Just (vr 1 3) + (vr 1 3, 4) `compatibleVR'` Nothing + (vr 2 3, 1) `compatibleVR'` Nothing + (vr 2 3, 2) `compatibleVR'` Just (vr 2 2) + (vr 2 3, 3) `compatibleVR'` Just (vr 2 3) + (vr 2 4, 1) `compatibleVR'` Nothing + (vr 2 4, 3) `compatibleVR'` Just (vr 2 3) + (vr 2 4, 4) `compatibleVR'` Just (vr 2 4) it "should check if version is compatible" $ do isCompatible @T (Version 1) (vr 1 2) `shouldBe` True isCompatible @T (Version 2) (vr 1 2) `shouldBe` True @@ -63,3 +86,16 @@ versionRangeTests = modifyMaxSuccess (const 1000) $ do case compatibleVersion vr1 vr2 of Just (Compatible v') -> Just v' `shouldBe` v Nothing -> Nothing `shouldBe` v + compatibleVR :: (VersionRange T, VersionRange T) -> Maybe (VersionRange T) -> Expectation + (vr1, vr2) `compatibleVR` vr' = do + (vr1, vr2) `checkCompatibleVR` vr' + (vr2, vr1) `checkCompatibleVR` vr' + (vr1, vr2) `checkCompatibleVR` vr' = + case compatibleVRange vr1 vr2 of + Just (Compatible vr'') -> Just vr'' `shouldBe` vr' + Nothing -> Nothing `shouldBe` vr' + compatibleVR' :: (VersionRange T, Word16) -> Maybe (VersionRange T) -> Expectation + (vr1, v2) `compatibleVR'` vr' = + case compatibleVRange' vr1 (Version v2) of + Just (Compatible vr'') -> Just vr'' `shouldBe` vr' + Nothing -> Nothing `shouldBe` vr' diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index 5f38cc639..0152625a8 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -14,6 +14,7 @@ import Simplex.FileTransfer.Client import Simplex.FileTransfer.Description import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration, defaultInactiveClientExpiration, supportedXFTPhandshakes) +import Simplex.FileTransfer.Transport (supportedFileServerVRange) import Simplex.Messaging.Protocol (XFTPServer) import Simplex.Messaging.Transport (ALPN) import Simplex.Messaging.Transport.Server @@ -118,6 +119,7 @@ testXFTPServerConfig_ alpn = caCertificateFile = "tests/fixtures/ca.crt", privateKeyFile = "tests/fixtures/server.key", certificateFile = "tests/fixtures/server.crt", + xftpServerVRange = supportedFileServerVRange, logStatsInterval = Nothing, logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", From 5cafd9d5c494cbc2d70c07e43aef31ac92f9883b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 9 May 2024 09:20:57 +0100 Subject: [PATCH 043/107] server: more efficient responses to batch subscriptions (#1137) * server: more efficient responses to batch subscriptions * comments * comment * enable tests * LogError --- src/Simplex/Messaging/Server.hs | 32 ++++++++++++++++++-------- tests/AgentTests.hs | 14 +++++------ tests/AgentTests/FunctionalAPITests.hs | 28 +++++++++++++++++++--- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 5415ebd65..895ba28ae 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -53,7 +53,8 @@ import Data.Either (fromRight, partitionEithers) import Data.Functor (($>)) import Data.Int (Int64) import qualified Data.IntMap.Strict as IM -import Data.List (intercalate) +import Data.List (intercalate, mapAccumR) +import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M import Data.Maybe (isNothing) @@ -482,16 +483,29 @@ send :: Transport c => THandleSMP c 'TServer -> Client -> IO () send h@THandle {params} Client {sndQ, sessionId, sndActiveAt} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " send" forever $ do - ts <- atomically $ L.sortWith tOrder <$> readTBQueue sndQ - -- TODO we can authorize responses as well - void . liftIO . tPut h $ L.map (\t -> Right (Nothing, encodeTransmission params t)) ts + sendTransmissions =<< atomically (readTBQueue sndQ) atomically . writeTVar sndActiveAt =<< liftIO getSystemTime where - tOrder :: Transmission BrokerMsg -> Int - tOrder (_, _, cmd) = case cmd of - MSG {} -> 0 - NMSG {} -> 0 - _ -> 1 + sendTransmissions :: NonEmpty (Transmission BrokerMsg) -> IO () + sendTransmissions ts + | L.length ts <= 2 = tSend ts + | otherwise = do + let (msgs, ts') = mapAccumR splitMessages [] ts + -- If the request had batched subscriptions (L.length ts > 2) + -- this will reply OK to all SUBs in the first batched transmission, + -- to reduce client timeouts. + tSend ts' + -- After that all messages will be sent in separate transmissions, + -- without any client response timeouts. + mapM_ tSend (L.nonEmpty msgs) + where + splitMessages :: [Transmission BrokerMsg] -> Transmission BrokerMsg -> ([Transmission BrokerMsg], Transmission BrokerMsg) + splitMessages msgs t@(corrId, entId, cmd) = case cmd of + -- replace MSG response with OK, accumulating MSG in a separate list. + MSG {} -> ((CorrId "", entId, cmd) : msgs, (corrId, entId, OK)) + _ -> (msgs, t) + tSend :: NonEmpty (Transmission BrokerMsg) -> IO () + tSend = void . tPut h . L.map (\t -> Right (Nothing, encodeTransmission params t)) disconnectTransport :: Transport c => THandle v c 'TServer -> TVar SystemTime -> TVar SystemTime -> ExpirationConfig -> IO Bool -> IO () disconnectTransport THandle {connection, params = THandleParams {sessionId}} rcvActiveAt sndActiveAt expCfg noSubscriptions = do diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index b890c2c00..32610b54e 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -27,9 +27,9 @@ import GHC.Stack (withFrozenCallStack) import Network.HTTP.Types (urlEncode) import SMPAgentClient import SMPClient (testKeyHash, testPort, testPort2, testStoreLogFile, withSmpServer, withSmpServerStoreLogOn) -import Simplex.Messaging.Agent.Protocol hiding (MID, CONF, INFO, REQ) +import Simplex.Messaging.Agent.Protocol hiding (CONF, INFO, MID, REQ) import qualified Simplex.Messaging.Agent.Protocol as A -import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOn, pattern IKPQOff, pattern PQEncOn, pattern PQSupportOn, pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ErrorType (..)) @@ -547,10 +547,10 @@ testResumeDeliveryQuotaExceeded _ alice bob = do bob <#= \case ("", "alice", Msg "message 4") -> True; _ -> False bob #: ("4", "alice", "ACK 7") #> ("4", "alice", OK) inAnyOrder - (tGetAgent alice) - [ \case ("", c, Right (SENT 8)) -> c == "bob"; _ -> False, - \case ("", c, Right QCONT) -> c == "bob"; _ -> False - ] + (tGetAgent alice) + [ \case ("", c, Right (SENT 8)) -> c == "bob"; _ -> False, + \case ("", c, Right QCONT) -> c == "bob"; _ -> False + ] bob <#= \case ("", "alice", Msg "over quota") -> True; _ -> False -- message 8 is skipped because of alice agent sending "QCONT" message bob #: ("5", "alice", "ACK 9") #> ("5", "alice", OK) @@ -580,7 +580,7 @@ enableKEMStr _ = "" pqConnModeStr :: InitialKeys -> ByteString pqConnModeStr (IKNoPQ PQSupportOff) = "" -pqConnModeStr pq = " " <> strEncode pq +pqConnModeStr pq = " " <> strEncode pq sendMessage :: Transport c => (c, ConnId) -> (c, ConnId) -> ByteString -> IO () sendMessage (h1, name1) (h2, name2) msg = do diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index cdcf5baed..59e433ea5 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -58,10 +58,10 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isRight) import Data.Int (Int64) -import Data.List (nub) +import Data.List (find, nub) import Data.List.NonEmpty (NonEmpty) import qualified Data.Map as M -import Data.Maybe (isNothing) +import Data.Maybe (isJust, isNothing) import qualified Data.Set as S import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.Clock.System (SystemTime (..), getSystemTime) @@ -337,6 +337,9 @@ functionalAPITests t = do skip "faster version of the previous test (200 subscriptions gets very slow with test coverage)" $ it "should subscribe to multiple (6) subscriptions with batching" $ testBatchedSubscriptions 6 3 t + it "should subscribe to multiple connections with pending messages" $ + withSmpServer t $ + testBatchedPendingMessages 10 5 describe "Async agent commands" $ do it "should connect using async agent commands" $ withSmpServer t testAsyncCommands @@ -1534,7 +1537,7 @@ testBatchedSubscriptions :: Int -> Int -> ATransport -> IO () testBatchedSubscriptions nCreate nDel t = withAgentClientsCfgServers2 agentCfg agentCfg initAgentServers2 $ \a b -> do conns <- runServers $ do - conns <- replicateM (nCreate :: Int) $ makeConnection_ PQSupportOff a b + conns <- replicateM nCreate $ makeConnection_ PQSupportOff a b forM_ conns $ \(aId, bId) -> exchangeGreetings_ PQEncOff a bId b aId let (aIds', bIds') = unzip $ take nDel conns delete a bIds' @@ -1593,6 +1596,25 @@ testBatchedSubscriptions nCreate nDel t = killThread t1 pure res +testBatchedPendingMessages :: Int -> Int -> IO () +testBatchedPendingMessages nCreate nMsgs = + withA $ \a -> do + conns <- withB $ \b -> runRight $ do + replicateM nCreate $ makeConnection a b + let msgConns = take nMsgs conns + runRight_ $ forM_ msgConns $ \(_, bId) -> sendMessage a bId SMP.noMsgFlags "hello" + replicateM_ nMsgs $ get a =##> \case ("", cId, SENT _) -> isJust $ find ((cId ==) . snd) msgConns; _ -> False + withB $ \b -> runRight_ $ do + r <- subscribeConnections b $ map fst conns + liftIO $ all isRight r `shouldBe` True + replicateM_ nMsgs $ do + ("", cId, Msg' msgId _ "hello") <- get b + liftIO $ isJust (find ((cId ==) . fst) msgConns) `shouldBe` True + ackMessage b cId msgId Nothing + where + withA = withAgent 1 agentCfg initAgentServers testDB + withB = withAgent 2 agentCfg initAgentServers testDB2 + testAsyncCommands :: IO () testAsyncCommands = withAgentClients2 $ \alice bob -> runRight_ $ do From b48215d341f6b7425fa108b88d1ec11990c66ba5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 9 May 2024 15:36:02 +0100 Subject: [PATCH 044/107] proxy: additional configuration for SOCKS proxy usage in SMP proxy client (#1138) * proxy: additional configuration for SOCKS proxy usage in SMP proxy client * update --- src/Simplex/FileTransfer/Client.hs | 6 ++-- src/Simplex/Messaging/Client.hs | 29 ++++++++++++++---- src/Simplex/Messaging/Server/Main.hs | 45 +++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 2e0585e30..7875542a6 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -99,11 +99,11 @@ defaultXFTPClientConfig = getXFTPClient :: TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do - let tcConfig = (transportClientConfig xftpNetworkConfig) {alpn = clientALPN} - http2Config = xftpHTTP2Config tcConfig config - username = proxyUsername transportSession + let username = proxyUsername transportSession ProtocolServer _ host port keyHash = srv useHost <- liftEither $ chooseTransportHost xftpNetworkConfig host + let tcConfig = (transportClientConfig xftpNetworkConfig useHost) {alpn = clientALPN} + http2Config = xftpHTTP2Config tcConfig config clientVar <- newTVarIO Nothing let usePort = if null port then "443" else port clientDisconnected = readTVarIO clientVar >>= mapM_ disconnected diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 59fbd4c12..c931aefc2 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -69,6 +69,7 @@ module Simplex.Messaging.Client NetworkConfig (..), TransportSessionMode (..), HostMode (..), + SocksMode (..), SMPProxyMode (..), SMPProxyFallback (..), defaultClientConfig, @@ -214,10 +215,20 @@ data HostMode HMPublic deriving (Eq, Show) +data SocksMode + = -- | always use SOCKS proxy when enabled + SMAlways + | -- | use SOCKS proxy only for .onion hosts when no public host is available + -- This mode is used in SMP proxy to minimize SOCKS proxy usage. + SMOnion + deriving (Eq, Show) + -- | network configuration for the client data NetworkConfig = NetworkConfig { -- | use SOCKS5 proxy socksProxy :: Maybe SocksProxy, + -- | when to use SOCKS proxy + socksMode :: SocksMode, -- | determines critera which host is chosen from the list hostMode :: HostMode, -- | if above criteria is not met, if the below setting is True return error, otherwise use the first host @@ -267,6 +278,7 @@ defaultNetworkConfig :: NetworkConfig defaultNetworkConfig = NetworkConfig { socksProxy = Nothing, + socksMode = SMAlways, hostMode = HMOnionViaSocks, requiredHostMode = False, sessionMode = TSMUser, @@ -282,9 +294,14 @@ defaultNetworkConfig = logTLSErrors = False } -transportClientConfig :: NetworkConfig -> TransportClientConfig -transportClientConfig NetworkConfig {socksProxy, tcpConnectTimeout, tcpKeepAlive, logTLSErrors} = - TransportClientConfig {socksProxy, tcpConnectTimeout, tcpKeepAlive, logTLSErrors, clientCredentials = Nothing, alpn = Nothing} +transportClientConfig :: NetworkConfig -> TransportHost -> TransportClientConfig +transportClientConfig NetworkConfig {socksProxy, socksMode, tcpConnectTimeout, tcpKeepAlive, logTLSErrors} host = + TransportClientConfig {socksProxy = useSocksProxy socksMode, tcpConnectTimeout, tcpKeepAlive, logTLSErrors, clientCredentials = Nothing, alpn = Nothing} + where + useSocksProxy SMAlways = socksProxy + useSocksProxy SMOnion = case host of + THOnionHost _ -> socksProxy + _ -> Nothing {-# INLINE transportClientConfig #-} -- | protocol client configuration. @@ -298,7 +315,7 @@ data ProtocolClientConfig v = ProtocolClientConfig clientALPN :: Maybe [ALPN], -- | client-server protocol version range serverVRange :: VersionRange v, - -- | agree shared session secret (used in SMP proxy) + -- | agree shared session secret (used in SMP proxy for additional encryption layer) agreeSecret :: Bool } @@ -411,7 +428,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize runClient :: (ServiceName, ATransport) -> TransportHost -> PClient v err msg -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) runClient (port', ATransport t) useHost c = do cVar <- newEmptyTMVarIO - let tcConfig = (transportClientConfig networkConfig) {alpn = clientALPN} + let tcConfig = (transportClientConfig networkConfig useHost) {alpn = clientALPN} username = proxyUsername transportSession action <- async $ @@ -994,6 +1011,8 @@ authTransmission thAuth pKey_ nonce t = traverse authenticate pKey_ $(J.deriveJSON (enumJSON $ dropPrefix "HM") ''HostMode) +$(J.deriveJSON (enumJSON $ dropPrefix "SM") ''SocksMode) + $(J.deriveJSON (enumJSON $ dropPrefix "TSM") ''TransportSessionMode) $(J.deriveJSON (enumJSON $ dropPrefix "SPM") ''SMPProxyMode) diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index 39f753af0..418ef36c8 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -14,11 +14,12 @@ import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) import Data.Ini (lookupValue, readIniFile) import Data.Maybe (fromMaybe) +import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Network.Socket (HostName) import Options.Applicative -import Simplex.Messaging.Client (ProtocolClientConfig (..)) +import Simplex.Messaging.Client (HostMode (..), NetworkConfig (..), ProtocolClientConfig (..), SocksMode (..), defaultNetworkConfig) import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -132,7 +133,7 @@ smpServerCLI cfgPath logPath = ) <> "\n\n\ \# control_port_admin_password:\n\ - \# control_port_user_password:\n\ + \# control_port_user_password:\n\n\ \[TRANSPORT]\n\ \# host is only used to print server address on start\n" <> ("host: " <> host <> "\n") @@ -140,6 +141,18 @@ smpServerCLI cfgPath logPath = <> "log_tls_errors: off\n\ \websockets: off\n\ \# control_port: 5224\n\n\ + \[PROXY]\n\ + \# Network configuration for SMP proxy client.\n\ + \# `host_mode` can be 'public' (default) or 'onion'.\n\ + \# It defines prefferred hostname for destination servers with multiple hostnames.\n\ + \# host_mode: public\n\ + \# required_host_mode: off\n\n\ + \# SOCKS proxy port for forwarding messages to destination servers.\n\ + \# You may need a separate instance of SOCKS proxy for incoming single-hop requests.\n\ + \# socks_proxy: localhost:9050\n\n\ + \# `socks_mode` can be 'onion' for SOCKS proxy to be used for .onion destination hosts only (default)\n\ + \# or 'always' to be used for all destination hosts (can be used if it is an .onion server).\n\ + \# socks_mode: onion\n\n\ \[INACTIVE_CLIENTS]\n\ \# TTL and interval to check inactive clients\n\ \disconnect: off\n" @@ -218,9 +231,33 @@ smpServerCLI cfgPath logPath = alpn = Just supportedSMPHandshakes }, controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini, - smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, agreeSecret = True}}, - allowSMPProxy = True -- TODO: "get from INI" + smpAgentCfg = + defaultSMPClientAgentConfig + { smpCfg = + (smpCfg defaultSMPClientAgentConfig) + { serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, + agreeSecret = True, + networkConfig = + defaultNetworkConfig + { socksProxy = either error id <$> strDecodeIni "PROXY" "socks_proxy" ini, + socksMode = either (const SMOnion) textToSocksMode $ lookupValue "PROXY" "socks_mode" ini, + hostMode = either (const HMPublic) textToHostMode $ lookupValue "PROXY" "host_mode" ini, + requiredHostMode = fromMaybe False $ iniOnOff "PROXY" "required_host_mode" ini + } + } + }, + allowSMPProxy = True } + textToSocksMode :: Text -> SocksMode + textToSocksMode = \case + "always" -> SMAlways + "onion" -> SMOnion + s -> error . T.unpack $ "Invalid socks_mode: " <> s + textToHostMode :: Text -> HostMode + textToHostMode = \case + "public" -> HMPublic + "onion" -> HMOnionViaSocks + s -> error . T.unpack $ "Invalid host_mode: " <> s data CliCommand = Init InitOptions From b7afb725fd5cdfde67a82303535aea0367121023 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 10 May 2024 10:55:19 +0100 Subject: [PATCH 045/107] proxy: send MWARN event to user on server version or host more errors (#1140) * proxy: include delivery path in SENT event * send MWARN event to user on server version or host more errors * Revert "proxy: include delivery path in SENT event" This reverts commit 5c476718ec84f4b9f7e2eca6a7ac7c3b260c0275. --- src/Simplex/Messaging/Agent.hs | 6 +++++- src/Simplex/Messaging/Agent/Client.hs | 26 ++++++++++++++----------- src/Simplex/Messaging/Agent/Protocol.hs | 7 +++++++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 3a0e13157..be0d35cb4 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -1358,7 +1358,11 @@ runSmpQueueMsgDelivery c@AgentClient {subQ} ConnData {connId} sq (Worker {doWork | temporaryOrHostError e -> do let msgTimeout = if msgType == AM_HELLO_ then helloTimeout else messageTimeout expireTs <- addUTCTime (-msgTimeout) <$> liftIO getCurrentTime - if internalTs < expireTs then notifyDelMsgs msgId e expireTs else retrySndMsg RIFast + if internalTs < expireTs + then notifyDelMsgs msgId e expireTs + else do + when (serverHostError e) $ notify $ MWARN (unId msgId) e + retrySndMsg RIFast | otherwise -> notifyDel msgId err where retrySndMsg riMode = do diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 133fae96a..f2c43fc67 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -46,6 +46,7 @@ module Simplex.Messaging.Agent.Client sendInvitation, temporaryAgentError, temporaryOrHostError, + serverHostError, secureQueue, enableQueueNotifications, enableQueuesNtfs, @@ -616,7 +617,7 @@ getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq Left e -> do liftIO $ incClientStat c userId clnt "PROXY" $ strEncode e atomically $ do - unless (persistentProxyError e) $ do + unless (serverHostError e) $ do removeSessVar rv destSrv prs TM.delete destSess smpProxiedRelays putTMVar (sessionVar rv) (Left e) @@ -1060,7 +1061,7 @@ sendOrProxySMPMessage c userId destSrv cmdStr spKey_ senderId msgFlags msg = do case r of Right r' -> pure r' Left e - | persistentProxyError e -> ifM (atomically directAllowed) (sendDirectly destSess $> Nothing) (throwE e) + | serverHostError e -> ifM (atomically directAllowed) (sendDirectly destSess $> Nothing) (throwE e) | otherwise -> throwE e sendDirectly tSess = withLogClient_ c tSess senderId ("SEND " <> cmdStr) $ \(SMPConnectedClient smp _) -> @@ -1303,17 +1304,20 @@ temporaryAgentError = \case _ -> False temporaryOrHostError :: AgentErrorType -> Bool -temporaryOrHostError = \case - BROKER _ HOST -> True - SMP (SMP.PROXY (SMP.BROKER HOST)) -> True - PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER HOST))) -> True - e -> temporaryAgentError e +temporaryOrHostError e = temporaryAgentError e || serverHostError e +{-# INLINE temporaryOrHostError #-} -persistentProxyError :: AgentErrorType -> Bool -persistentProxyError = \case - BROKER _ (SMP.TRANSPORT TEVersion) -> True - SMP (SMP.PROXY (SMP.BROKER (SMP.TRANSPORT TEVersion))) -> True +serverHostError :: AgentErrorType -> Bool +serverHostError = \case + BROKER _ e -> brokerHostError e + SMP (SMP.PROXY (SMP.BROKER e)) -> brokerHostError e + PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER e))) -> brokerHostError e _ -> False + where + brokerHostError = \case + HOST -> True + SMP.TRANSPORT TEVersion -> True + _ -> False -- | Subscribe to queues. The list of results can have a different order. subscribeQueues :: AgentClient -> [RcvQueue] -> AM' [(RcvQueue, Either AgentErrorType ())] diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 7160863f1..a18f64064 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -396,6 +396,7 @@ data ACommand (p :: AParty) (e :: AEntity) where SEND :: PQEncryption -> MsgFlags -> MsgBody -> ACommand Client AEConn MID :: AgentMsgId -> PQEncryption -> ACommand Agent AEConn SENT :: AgentMsgId -> Maybe SMPServer -> ACommand Agent AEConn + MWARN :: AgentMsgId -> AgentErrorType -> ACommand Agent AEConn MERR :: AgentMsgId -> AgentErrorType -> ACommand Agent AEConn MERRS :: NonEmpty AgentMsgId -> AgentErrorType -> ACommand Agent AEConn MSG :: MsgMeta -> MsgFlags -> MsgBody -> ACommand Agent AEConn @@ -459,6 +460,7 @@ data ACommandTag (p :: AParty) (e :: AEntity) where SEND_ :: ACommandTag Client AEConn MID_ :: ACommandTag Agent AEConn SENT_ :: ACommandTag Agent AEConn + MWARN_ :: ACommandTag Agent AEConn MERR_ :: ACommandTag Agent AEConn MERRS_ :: ACommandTag Agent AEConn MSG_ :: ACommandTag Agent AEConn @@ -515,6 +517,7 @@ aCommandTag = \case SEND {} -> SEND_ MID {} -> MID_ SENT {} -> SENT_ + MWARN {} -> MWARN_ MERR {} -> MERR_ MERRS {} -> MERRS_ MSG {} -> MSG_ @@ -1668,6 +1671,7 @@ instance StrEncoding ACmdTag where "SEND" -> t SEND_ "MID" -> ct MID_ "SENT" -> ct SENT_ + "MWARN" -> ct MWARN_ "MERR" -> ct MERR_ "MERRS" -> ct MERRS_ "MSG" -> ct MSG_ @@ -1726,6 +1730,7 @@ instance (APartyI p, AEntityI e) => StrEncoding (ACommandTag p e) where SEND_ -> "SEND" MID_ -> "MID" SENT_ -> "SENT" + MWARN_ -> "MWARN" MERR_ -> "MERR" MERRS_ -> "MERRS" MSG_ -> "MSG" @@ -1797,6 +1802,7 @@ commandP binaryP = RSYNC_ -> s (RSYNC <$> strP_ <*> strP <*> strP) MID_ -> s (MID <$> A.decimal <*> _strP) SENT_ -> s (SENT <$> A.decimal <*> _strP) + MWARN_ -> s (MWARN <$> A.decimal <* A.space <*> strP) MERR_ -> s (MERR <$> A.decimal <* A.space <*> strP) MERRS_ -> s (MERRS <$> strP_ <*> strP) MSG_ -> s (MSG <$> strP <* A.space <*> smpP <* A.space <*> binaryP) @@ -1860,6 +1866,7 @@ serializeCommand = \case SEND pqEnc msgFlags msgBody -> B.unwords [s SEND_, s pqEnc, smpEncode msgFlags, serializeBinary msgBody] MID mId pqEnc -> s (MID_, mId, pqEnc) SENT mId proxySrv_ -> s (SENT_, mId, proxySrv_) + MWARN mId e -> s (MWARN_, mId, e) MERR mId e -> s (MERR_, mId, e) MERRS mIds e -> s (MERRS_, mIds, e) MSG msgMeta msgFlags msgBody -> B.unwords [s MSG_, s msgMeta, smpEncode msgFlags, serializeBinary msgBody] From dc111437fda043b4921cf31f6e402a9644ad3324 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 10 May 2024 15:15:43 +0100 Subject: [PATCH 046/107] 5.7.3.0 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 7a536f7bf..093293e80 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.2.0 +version: 5.7.3.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 7539d675a..808e52ae0 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.2.0 +version: 5.7.3.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 727fd8b8f5eed729ee82f25f831e86df502b27b8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 10 May 2024 22:19:11 +0100 Subject: [PATCH 047/107] server: more efficient response to batched subscriptions (#1141) * server: more efficient response to batched subscriptions * add sndMsgQ for interleaving messages with replies * remove redundant liftIO * refactor * refactor2 * rename * fix * diff * remove comment * remove comment --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- src/Simplex/Messaging/Server.hs | 50 +++++++++++++++---------- src/Simplex/Messaging/Server/Env/STM.hs | 4 +- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 895ba28ae..85beccd6b 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -411,7 +411,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do hPutStrLn h "AUTH" runClientTransport :: Transport c => THandleSMP c 'TServer -> M () -runClientTransport th@THandle {params = THandleParams {thVersion, sessionId}} = do +runClientTransport h@THandle {params = THandleParams {thVersion, sessionId}} = do q <- asks $ tbqSize . config ts <- liftIO getSystemTime active <- asks clients @@ -422,11 +422,12 @@ runClientTransport th@THandle {params = THandleParams {thVersion, sessionId}} = pure new s <- asks server expCfg <- asks $ inactiveClientExpiration . config + th <- newMVar h -- put TH under a fair lock to interleave messages and command responses labelMyThread . B.unpack $ "client $" <> encode sessionId - raceAny_ ([liftIO $ send th c, client c s, receive th c] <> disconnectThread_ c expCfg) + raceAny_ ([liftIO $ send th c, liftIO $ sendMsg th c, client c s, receive h c] <> disconnectThread_ c expCfg) `finally` clientDisconnected c where - disconnectThread_ c (Just expCfg) = [liftIO $ disconnectTransport th (rcvActiveAt c) (sndActiveAt c) expCfg (noSubscriptions c)] + disconnectThread_ c (Just expCfg) = [liftIO $ disconnectTransport h (rcvActiveAt c) (sndActiveAt c) expCfg (noSubscriptions c)] disconnectThread_ _ _ = [] noSubscriptions c = atomically $ (&&) <$> TM.null (subscriptions c) <*> TM.null (ntfSubscriptions c) @@ -459,10 +460,10 @@ cancelSub sub = _ -> return () receive :: Transport c => THandleSMP c 'TServer -> Client -> M () -receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActiveAt, sessionId} = do +receive h@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActiveAt, sessionId} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " receive" forever $ do - ts <- L.toList <$> liftIO (tGet th) + ts <- L.toList <$> liftIO (tGet h) atomically . writeTVar rcvActiveAt =<< liftIO getSystemTime as <- partitionEithers <$> mapM cmdAction ts write sndQ $ fst as @@ -479,33 +480,41 @@ receive th@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActi VRFailed -> Left (corrId, queueId, ERR AUTH) write q = mapM_ (atomically . writeTBQueue q) . L.nonEmpty -send :: Transport c => THandleSMP c 'TServer -> Client -> IO () -send h@THandle {params} Client {sndQ, sessionId, sndActiveAt} = do +send :: Transport c => MVar (THandleSMP c 'TServer) -> Client -> IO () +send th c@Client {sndQ, msgQ, sessionId} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " send" - forever $ do - sendTransmissions =<< atomically (readTBQueue sndQ) - atomically . writeTVar sndActiveAt =<< liftIO getSystemTime + forever $ atomically (readTBQueue sndQ) >>= sendTransmissions where sendTransmissions :: NonEmpty (Transmission BrokerMsg) -> IO () sendTransmissions ts - | L.length ts <= 2 = tSend ts + | L.length ts <= 2 = tSend th c ts | otherwise = do - let (msgs, ts') = mapAccumR splitMessages [] ts + let (msgs_, ts') = mapAccumR splitMessages [] ts -- If the request had batched subscriptions (L.length ts > 2) -- this will reply OK to all SUBs in the first batched transmission, -- to reduce client timeouts. - tSend ts' + tSend th c ts' -- After that all messages will be sent in separate transmissions, - -- without any client response timeouts. - mapM_ tSend (L.nonEmpty msgs) + -- without any client response timeouts, and allowing them to interleave + -- with other requests responses. + mapM_ (atomically . writeTBQueue msgQ) $ L.nonEmpty msgs_ where splitMessages :: [Transmission BrokerMsg] -> Transmission BrokerMsg -> ([Transmission BrokerMsg], Transmission BrokerMsg) splitMessages msgs t@(corrId, entId, cmd) = case cmd of -- replace MSG response with OK, accumulating MSG in a separate list. MSG {} -> ((CorrId "", entId, cmd) : msgs, (corrId, entId, OK)) _ -> (msgs, t) - tSend :: NonEmpty (Transmission BrokerMsg) -> IO () - tSend = void . tPut h . L.map (\t -> Right (Nothing, encodeTransmission params t)) + +sendMsg :: Transport c => MVar (THandleSMP c 'TServer) -> Client -> IO () +sendMsg th c@Client {msgQ, sessionId} = do + labelMyThread . B.unpack $ "client $" <> encode sessionId <> " sendMsg" + forever $ atomically (readTBQueue msgQ) >>= mapM_ (\t -> tSend th c [t]) + +tSend :: Transport c => MVar (THandleSMP c 'TServer) -> Client -> NonEmpty (Transmission BrokerMsg) -> IO () +tSend th Client {sndActiveAt} ts = do + withMVar th $ \h@THandle {params} -> + void . tPut h $ L.map (\t -> Right (Nothing, encodeTransmission params t)) ts + atomically . writeTVar sndActiveAt =<< liftIO getSystemTime disconnectTransport :: Transport c => THandle v c 'TServer -> TVar SystemTime -> TVar SystemTime -> ExpirationConfig -> IO Bool -> IO () disconnectTransport THandle {connection, params = THandleParams {sessionId}} rcvActiveAt sndActiveAt expCfg noSubscriptions = do @@ -989,9 +998,10 @@ saveServerMessages keepMsgs = asks (storeMsgsFile . config) >>= mapM_ saveMessag >>= mapM_ (B.hPutStrLn h . strEncode . MLRv3 rId) restoreServerMessages :: M Int -restoreServerMessages = asks (storeMsgsFile . config) >>= \case - Just f -> ifM (doesFileExist f) (restoreMessages f) (pure 0) - Nothing -> pure 0 +restoreServerMessages = + asks (storeMsgsFile . config) >>= \case + Just f -> ifM (doesFileExist f) (restoreMessages f) (pure 0) + Nothing -> pure 0 where restoreMessages f = do logInfo $ "restoring messages from file " <> T.pack f diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index 6794ad979..bd8262f07 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -128,6 +128,7 @@ data Client = Client ntfSubscriptions :: TMap NotifierId (), rcvQ :: TBQueue (NonEmpty (Maybe QueueRec, Transmission Cmd)), sndQ :: TBQueue (NonEmpty (Transmission BrokerMsg)), + msgQ :: TBQueue (NonEmpty (Transmission BrokerMsg)), endThreads :: TVar (IntMap (Weak ThreadId)), endThreadSeq :: TVar Int, thVersion :: VersionSMP, @@ -161,12 +162,13 @@ newClient nextClientId qSize thVersion sessionId createdAt = do ntfSubscriptions <- TM.empty rcvQ <- newTBQueue qSize sndQ <- newTBQueue qSize + msgQ <- newTBQueue qSize endThreads <- newTVar IM.empty endThreadSeq <- newTVar 0 connected <- newTVar True rcvActiveAt <- newTVar createdAt sndActiveAt <- newTVar createdAt - return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt} + return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, msgQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt} newSubscription :: SubscriptionThread -> STM Sub newSubscription subThread = do From acc7faea11453e291393440816a060098faabf86 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 10 May 2024 22:24:00 +0100 Subject: [PATCH 048/107] 5.7.3.1 --- CHANGELOG.md | 15 +++++++++++++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5792195d..f0093ba92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# 5.7.3 + +SMP/NTF protocol: +- add ALPN for handshake version negotiation, similar to XFTP (to preserve backwards compatibility with the old clients). +- upgrade clients to versions v7/v2 of the protocols. + +SMP server: +- faster responses to subscription requests. + +XFTP client: +- fix network exception during file download treated as permanent file error. + +SMP agent: +- do not report subscription timeouts while client is offline. + # 5.7.2 SMP agent: diff --git a/package.yaml b/package.yaml index 093293e80..6d63ad488 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.3.0 +version: 5.7.3.1 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 808e52ae0..de10da8b8 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.3.0 +version: 5.7.3.1 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 8516b0dd5b8bdee600fdcfca43d6e15b97bbf1c0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 11 May 2024 17:11:28 +0100 Subject: [PATCH 049/107] proxy: negotiate client-relay version, include it in PFWD commands and in encrypted forwarded transmissions (#1144) * proxy: negotiate client-relay version, include it in PFWD commands and in encrypted forwarded transmissions * rename * inline * comment * use correct server version when encoding forwarded commands --- src/Simplex/Messaging/Client.hs | 17 +++++----- src/Simplex/Messaging/Protocol.hs | 17 +++++----- src/Simplex/Messaging/Server.hs | 25 +++++++------- src/Simplex/Messaging/Server/Env/STM.hs | 6 ++-- src/Simplex/Messaging/Transport.hs | 44 ++++++++++++++++++------- 5 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index c931aefc2..92dd4047b 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -819,11 +819,10 @@ proxySMPMessage :: MsgFlags -> MsgBody -> ExceptT SMPClientError IO (Either ProxyClientError ()) --- TODO use version -proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} (ProxiedRelay sessionId _v serverKey) spKey sId flags msg = do +proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} (ProxiedRelay sessionId v serverKey) spKey sId flags msg = do -- prepare params let serverThAuth = (\ta -> ta {serverPeerPubKey = serverKey}) <$> thAuth proxyThParams - serverThParams = proxyThParams {sessionId, thAuth = serverThAuth} + serverThParams = smpTHParamsSetVersion v proxyThParams {sessionId, thAuth = serverThAuth} (cmdPubKey, cmdPrivKey) <- liftIO . atomically $ C.generateKeyPair @'C.X25519 g let cmdSecret = C.dh' serverKey cmdPrivKey nonce@(C.CbNonce corrId) <- liftIO . atomically $ C.randomCbNonce g @@ -837,13 +836,13 @@ proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c TBTransmissions s _ _ : _ -> pure s et <- liftEitherWith PCECryptoError $ EncTransmission <$> C.cbEncrypt cmdSecret nonce b paddedProxiedMsgLength -- proxy interaction errors are wrapped - tryE (sendProtocolCommand_ c (Just nonce) Nothing sessionId (Cmd SProxiedClient (PFWD cmdPubKey et))) >>= \case + tryE (sendProtocolCommand_ c (Just nonce) Nothing sessionId (Cmd SProxiedClient (PFWD v cmdPubKey et))) >>= \case Right r -> case r of PRES (EncResponse er) -> do -- server interaction errors are thrown directly t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (C.reverseNonce nonce) er - case tParse proxyThParams t' of - t'' :| [] -> case tDecodeParseValidate proxyThParams t'' of + case tParse serverThParams t' of + t'' :| [] -> case tDecodeParseValidate serverThParams t'' of (_auth, _signed, (_c, _e, cmd)) -> case cmd of Right OK -> pure $ Right () Right (ERR e) -> throwE $ PCEProtocolError e -- this is the error from the destination relay @@ -862,15 +861,15 @@ proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c -- sends RFWD :: EncFwdTransmission -> Command Sender -- receives RRES :: EncFwdResponse -> BrokerMsg -- proxy should send PRES to the client with EncResponse -forwardSMPMessage :: SMPClient -> CorrId -> C.PublicKeyX25519 -> EncTransmission -> ExceptT SMPClientError IO EncResponse -forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = g}} fwdCorrId fwdKey fwdTransmission = do +forwardSMPMessage :: SMPClient -> CorrId -> VersionSMP -> C.PublicKeyX25519 -> EncTransmission -> ExceptT SMPClientError IO EncResponse +forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = g}} fwdCorrId fwdVersion fwdKey fwdTransmission = do -- prepare params sessSecret <- case thAuth thParams of Nothing -> throwError $ PCETransportError TENoServerAuth Just THAuthClient {sessSecret} -> maybe (throwError $ PCETransportError TENoServerAuth) pure sessSecret nonce <- liftIO . atomically $ C.randomCbNonce g -- wrap - let fwdT = FwdTransmission {fwdCorrId, fwdKey, fwdTransmission} + let fwdT = FwdTransmission {fwdCorrId, fwdVersion, fwdKey, fwdTransmission} eft = EncFwdTransmission $ C.cbEncryptNoPad sessSecret nonce (smpEncode fwdT) -- send sendProtocolCommand_ c (Just nonce) Nothing "" (Cmd SSender (RFWD eft)) >>= \case diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 250c76fcf..1812dce37 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -251,7 +251,7 @@ maxMessageLength v | otherwise = 16088 -- 16064 - always use this size to determine allowed ranges paddedProxiedMsgLength :: Int -paddedProxiedMsgLength = 16244 -- 16241 .. 16245 +paddedProxiedMsgLength = 16242 -- 16241 .. 16243 -- TODO v6.0 change to 16064 type MaxMessageLen = 16088 @@ -399,7 +399,7 @@ data Command (p :: Party) where -- - corrId: also used as a nonce to encrypt transmission to relay, corrId + 1 - from relay -- - key (1st param in the command) is used to agree DH secret for this particular transmission and its response -- Encrypted transmission should include session ID (tlsunique) from proxy-relay connection. - PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command ProxiedClient -- use CorrId as CbNonce, client to proxy + PFWD :: VersionSMP -> C.PublicKeyX25519 -> EncTransmission -> Command ProxiedClient -- use CorrId as CbNonce, client to proxy -- Transmission forwarded to relay: -- - entity ID: empty -- - corrId: unique correlation ID between proxy and relay, also used as a nonce to encrypt forwarded transmission @@ -434,16 +434,17 @@ newtype EncTransmission = EncTransmission ByteString data FwdTransmission = FwdTransmission { fwdCorrId :: CorrId, + fwdVersion :: VersionSMP, fwdKey :: C.PublicKeyX25519, fwdTransmission :: EncTransmission } instance Encoding FwdTransmission where - smpEncode FwdTransmission {fwdCorrId = CorrId corrId, fwdKey, fwdTransmission = EncTransmission t} = - smpEncode (corrId, fwdKey, Tail t) + smpEncode FwdTransmission {fwdCorrId = CorrId corrId, fwdVersion, fwdKey, fwdTransmission = EncTransmission t} = + smpEncode (corrId, fwdVersion, fwdKey, Tail t) smpP = do - (corrId, fwdKey, Tail t) <- smpP - pure FwdTransmission {fwdCorrId = CorrId corrId, fwdKey, fwdTransmission = EncTransmission t} + (corrId, fwdVersion, fwdKey, Tail t) <- smpP + pure FwdTransmission {fwdCorrId = CorrId corrId, fwdVersion, fwdKey, fwdTransmission = EncTransmission t} newtype EncFwdTransmission = EncFwdTransmission ByteString deriving (Show) @@ -1278,7 +1279,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PING -> e PING_ NSUB -> e NSUB_ PRXY host auth_ -> e (PRXY_, ' ', host, auth_) - PFWD pubKey (EncTransmission s) -> e (PFWD_, ' ', pubKey, Tail s) + PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s) RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) where e :: Encoding a => a -> ByteString @@ -1346,7 +1347,7 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where RFWD_ -> RFWD <$> (EncFwdTransmission . unTail <$> _smpP) CT SProxiedClient tag -> Cmd SProxiedClient <$> case tag of - PFWD_ -> PFWD <$> _smpP <*> (EncTransmission . unTail <$> smpP) + PFWD_ -> PFWD <$> _smpP <*> smpP <*> (EncTransmission . unTail <$> smpP) PRXY_ -> PRXY <$> _smpP <*> smpP CT SNotifier NSUB_ -> pure $ Cmd SNotifier NSUB diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 156f5da18..85a9073bc 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -89,6 +89,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Buffer (trimCR) import Simplex.Messaging.Transport.Server import Simplex.Messaging.Util +import Simplex.Messaging.Version import System.Exit (exitFailure) import System.IO (hPrint, hPutStrLn, hSetNewlineMode, universalNewlineMode) import System.Mem.Weak (deRefWeak) @@ -650,20 +651,21 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi proxyResp = \case Left err -> ERR $ smpProxyError err Right smp -> - let THandleParams {sessionId = srvSessId, thVersion, thAuth} = thParams smp - vr = supportedServerSMPRelayVRange -- TODO this should be destination relay version range - in if thVersion >= sendingProxySMPVersion - then case thAuth of + let THandleParams {sessionId = srvSessId, thVersion, thServerVRange, thAuth} = thParams smp + in case compatibleVRange thServerVRange proxiedSMPRelayVRange of + -- Cap the destination relay version range to prevent client version fingerprinting. + -- See comment for proxiedSMPRelayVersion. + Just (Compatible vr) | thVersion >= sendingProxySMPVersion -> case thAuth of Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey Nothing -> ERR $ transportErr TENoServerAuth - else ERR $ transportErr TEVersion - PFWD pubKey encBlock -> do + _ -> ERR $ transportErr TEVersion + PFWD fwdV pubKey encBlock -> do ProxyAgent {smpAgent} <- asks proxyAgent atomically (lookupSMPServerClient smpAgent sessId) >>= \case Just smp | v >= sendingProxySMPVersion -> liftIO $ either (ERR . smpProxyError) PRES <$> - runExceptT (forwardSMPMessage smp corrId pubKey encBlock) `catchError` (pure . Left . PCEIOError) + runExceptT (forwardSMPMessage smp corrId fwdV pubKey encBlock) `catchError` (pure . Left . PCEIOError) | otherwise -> pure . ERR $ transportErr TEVersion where THandleParams {thVersion = v} = thParams smp @@ -952,13 +954,14 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi sessSecret <- maybe (throwE $ transportErr TENoServerAuth) pure sessSecret' let proxyNonce = C.cbNonce $ bs corrId s' <- liftEitherWith (const CRYPTO) $ C.cbDecryptNoPad sessSecret proxyNonce s - FwdTransmission {fwdCorrId, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith (const $ CMD SYNTAX) $ smpDecode s' + FwdTransmission {fwdCorrId, fwdVersion, fwdKey, fwdTransmission = EncTransmission et} <- liftEitherWith (const $ CMD SYNTAX) $ smpDecode s' let clientSecret = C.dh' fwdKey serverPrivKey clientNonce = C.cbNonce $ bs fwdCorrId b <- liftEitherWith (const CRYPTO) $ C.cbDecrypt clientSecret clientNonce et + let clntTHParams = smpTHParamsSetVersion fwdVersion thParams' -- only allowing single forwarded transactions - t' <- case tParse thParams' b of - t :| [] -> pure $ tDecodeParseValidate thParams' t + t' <- case tParse clntTHParams b of + t :| [] -> pure $ tDecodeParseValidate clntTHParams t _ -> throwE BLOCK let clntThAuth = Just $ THAuthServer {serverPrivKey, sessSecret' = Just clientSecret} -- process forwarded SEND @@ -972,7 +975,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi _ -> pure (corrId', entId', ERR $ CMD PROHIBITED) -- encode response - r' <- case batchTransmissions (batch thParams') (blockSize thParams') [Right (Nothing, encodeTransmission thParams' r)] of + r' <- case batchTransmissions (batch clntTHParams) (blockSize clntTHParams) [Right (Nothing, encodeTransmission clntTHParams r)] of [] -> throwE INTERNAL -- at least 1 item is guaranteed from NonEmpty/Right TBError _ _ : _ -> throwE BLOCK TBTransmission b' _ : _ -> pure b' diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index 845d483b1..f23192aeb 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -146,8 +146,7 @@ data Client = Client connected :: TVar Bool, createdAt :: SystemTime, rcvActiveAt :: TVar SystemTime, - sndActiveAt :: TVar SystemTime, - proxyClient_ :: TVar (Maybe C.DhSecretX25519) -- this client is actually an SMP proxy + sndActiveAt :: TVar SystemTime } data SubscriptionThread = NoSub | SubPending | SubThread (Weak ThreadId) | ProhibitSub @@ -179,8 +178,7 @@ newClient nextClientId qSize thVersion sessionId createdAt = do connected <- newTVar True rcvActiveAt <- newTVar createdAt sndActiveAt <- newTVar createdAt - proxyClient_ <- newTVar Nothing - return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, msgQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt, proxyClient_} + return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, msgQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt} newSubscription :: SubscriptionThread -> STM Sub newSubscription subThread = do diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index f12168840..561e9c4de 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -36,6 +36,7 @@ module Simplex.Messaging.Transport supportedSMPHandshakes, supportedClientSMPRelayVRange, supportedServerSMPRelayVRange, + proxiedSMPRelayVRange, legacyServerSMPRelayVRange, currentClientSMPRelayVersion, legacyServerSMPRelayVersion, @@ -77,6 +78,7 @@ module Simplex.Messaging.Transport tGetBlock, sendHandshake, getHandshake, + smpTHParamsSetVersion, ) where @@ -155,13 +157,21 @@ sendingProxySMPVersion :: VersionSMP sendingProxySMPVersion = VersionSMP 8 currentClientSMPRelayVersion :: VersionSMP -currentClientSMPRelayVersion = VersionSMP 7 +currentClientSMPRelayVersion = VersionSMP 8 legacyServerSMPRelayVersion :: VersionSMP legacyServerSMPRelayVersion = VersionSMP 6 currentServerSMPRelayVersion :: VersionSMP -currentServerSMPRelayVersion = VersionSMP 7 +currentServerSMPRelayVersion = VersionSMP 8 + +-- Max SMP protocol version to be used in e2e encrypted +-- connection between client and server, as defined by SMP proxy. +-- SMP proxy sets it to lower than its current version +-- to prevent client version fingerprinting by the +-- destination relays when clients upgrade at different times. +proxiedSMPRelayVersion :: VersionSMP +proxiedSMPRelayVersion = VersionSMP 8 -- minimal supported protocol version is 4 -- TODO remove code that supports sending commands without batching @@ -174,6 +184,10 @@ legacyServerSMPRelayVRange = mkVersionRange batchCmdsSMPVersion legacyServerSMPR supportedServerSMPRelayVRange :: VersionRangeSMP supportedServerSMPRelayVRange = mkVersionRange batchCmdsSMPVersion currentServerSMPRelayVersion +-- This range initially allows only version 8 - see the comment above. +proxiedSMPRelayVRange :: VersionRangeSMP +proxiedSMPRelayVRange = mkVersionRange sendingProxySMPVersion proxiedSMPRelayVersion + supportedSMPHandshakes :: [ALPN] supportedSMPHandshakes = ["smp/1"] @@ -497,7 +511,7 @@ smpServerHandshake serverSignKey c (k, pk) kh smpVRange = do throwE $ TEHandshake IDENTITY | otherwise -> case compatibleVRange' smpVersionRange v of - Just (Compatible vr) -> pure $ smpThHandleServer th v vr pk k' + Just (Compatible vr) -> pure $ smpTHandleServer th v vr pk k' Nothing -> throwE TEVersion -- | Client SMP transport handshake. @@ -521,25 +535,31 @@ smpClientHandshake c ks_ keyHash@(C.KeyHash kh) smpVRange = do (,certKey) <$> (C.x509ToPublic (pubKey, []) >>= C.pubKey) let v = maxVersion vr sendHandshake th $ ClientHandshake {smpVersion = v, keyHash, authPubKey = fst <$> ks_} - pure $ smpThHandleClient th v vr (snd <$> ks_) ck_ + pure $ smpTHandleClient th v vr (snd <$> ks_) ck_ Nothing -> throwE TEVersion -smpThHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> VersionRangeSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer -smpThHandleServer th v vr pk k_ = +smpTHandleServer :: forall c. THandleSMP c 'TServer -> VersionSMP -> VersionRangeSMP -> C.PrivateKeyX25519 -> Maybe C.PublicKeyX25519 -> THandleSMP c 'TServer +smpTHandleServer th v vr pk k_ = let thAuth = THAuthServer {serverPrivKey = pk, sessSecret' = (`C.dh'` pk) <$> k_} - in smpThHandle_ th v vr (Just thAuth) + in smpTHandle_ th v vr (Just thAuth) -smpThHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> VersionRangeSMP -> Maybe C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient -smpThHandleClient th v vr pk_ ck_ = +smpTHandleClient :: forall c. THandleSMP c 'TClient -> VersionSMP -> VersionRangeSMP -> Maybe C.PrivateKeyX25519 -> Maybe (C.PublicKeyX25519, (X.CertificateChain, X.SignedExact X.PubKey)) -> THandleSMP c 'TClient +smpTHandleClient th v vr pk_ ck_ = let thAuth = (\(k, ck) -> THAuthClient {serverPeerPubKey = k, serverCertKey = ck, sessSecret = C.dh' k <$> pk_}) <$> ck_ - in smpThHandle_ th v vr thAuth + in smpTHandle_ th v vr thAuth -smpThHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> VersionRangeSMP -> Maybe (THandleAuth p) -> THandleSMP c p -smpThHandle_ th@THandle {params} v vr thAuth = +smpTHandle_ :: forall c p. THandleSMP c p -> VersionSMP -> VersionRangeSMP -> Maybe (THandleAuth p) -> THandleSMP c p +smpTHandle_ th@THandle {params} v vr thAuth = -- TODO drop SMP v6: make thAuth non-optional let params' = params {thVersion = v, thServerVRange = vr, thAuth, implySessId = v >= authCmdsSMPVersion} in (th :: THandleSMP c p) {params = params'} +-- This function is only used with v >= 8, so currently it's a simple record update. +-- It may require some parameters update in the future, to be consistent with smpTHandle_. +smpTHParamsSetVersion :: VersionSMP -> THandleParams SMPVersion p -> THandleParams SMPVersion p +smpTHParamsSetVersion v params = params {thVersion = v} +{-# INLINE smpTHParamsSetVersion #-} + sendHandshake :: (Transport c, Encoding smp) => THandle v c p -> smp -> ExceptT TransportError IO () sendHandshake th = ExceptT . tPutBlock th . smpEncode From 103ae06d554340bc9af87209e2244faf1f7d5c9a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 11 May 2024 23:38:27 +0100 Subject: [PATCH 050/107] agent: remove critical error on subscription timeout (#1146) --- src/Simplex/Messaging/Agent/Client.hs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 59170ce2d..cfed6b932 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -637,9 +637,9 @@ reconnectSMPClient tc c tSess@(_, srv, _) qs = do Nothing -> do tc' <- atomically $ stateTVar tc $ \i -> (i + 1, i + 1) maxTC <- asks $ maxSubscriptionTimeouts . config - let err = if tc' >= maxTC then CRITICAL True else INTERNAL - msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess - atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ err msg) + when (tc' >= maxTC) $ do + let msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess + atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ INTERNAL msg) where resubscribe :: AM () resubscribe = do From 1339a8da1105ae836b3804113498dabbdf5901ee Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 11 May 2024 23:39:28 +0100 Subject: [PATCH 051/107] 5.7.4.0 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 6d63ad488..7ba64f531 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.3.1 +version: 5.7.4.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index de10da8b8..19c99acc4 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.3.1 +version: 5.7.4.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 91cc48aabe6f490a0829b7601fe024edf7a1216c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 12 May 2024 17:47:08 +0100 Subject: [PATCH 052/107] agent: do not mark subscriptions on expired sessions as active, mark delayed subscriptions as active on the same session, do not cancel sending expired commands (#1127) * agent: do not mark subscriptions on expired sessions as active, do mark delayed subscriptions as active on the same session, SUBOK response in the next SMP protocol version * client: prevent sub actions from zombie sessions (#1122) * client: prevent sub actions from zombie sessions * error handling * add AERR to pass background errors to client * switch to activeClientSession * put closeClient under activeClientSession * rename * remove AERR, do not skip processing * move check and state update to one transaction * catch extra UPs * fix * check queue is still pending before making it active --------- Co-authored-by: Evgeny Poberezkin * do not forward agent error * revert not expiring sending subs * fixes * track subscription responses better * add pending connection * Revert "revert not expiring sending subs" This reverts commit 4310a69391fc791015d86a7b8449156b5e441ffa. * do not expire sending commands * rename * fix race * function --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- package.yaml | 1 + simplexmq.cabal | 14 +- src/Simplex/FileTransfer/Agent.hs | 11 +- src/Simplex/Messaging/Agent.hs | 77 ++++++---- src/Simplex/Messaging/Agent/Client.hs | 119 ++++++++++----- src/Simplex/Messaging/Agent/Protocol.hs | 38 ++--- src/Simplex/Messaging/Client.hs | 140 ++++++++++-------- src/Simplex/Messaging/Client/Agent.hs | 4 +- src/Simplex/Messaging/Notifications/Server.hs | 12 +- src/Simplex/Messaging/Server.hs | 5 +- src/Simplex/Messaging/Session.hs | 4 + src/Simplex/Messaging/Transport.hs | 2 +- tests/AgentTests.hs | 8 +- tests/AgentTests/FunctionalAPITests.hs | 36 ++--- tests/AgentTests/NotificationTests.hs | 21 +-- tests/CoreTests/ProtocolErrorTests.hs | 3 + tests/XFTPAgent.hs | 20 ++- 17 files changed, 312 insertions(+), 203 deletions(-) diff --git a/package.yaml b/package.yaml index 7ba64f531..02a088e23 100644 --- a/package.yaml +++ b/package.yaml @@ -180,6 +180,7 @@ ghc-options: - -Wall - -Wcompat - -Werror=incomplete-patterns + - -Werror=missing-methods - -Wredundant-constraints - -Wincomplete-record-updates - -Wincomplete-uni-patterns diff --git a/simplexmq.cabal b/simplexmq.cabal index 19c99acc4..3366cb0b8 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -174,7 +174,7 @@ library src default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 include-dirs: cbits c-sources: @@ -255,7 +255,7 @@ executable ntf-server apps/ntf-server default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -330,7 +330,7 @@ executable smp-agent apps/smp-agent default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -405,7 +405,7 @@ executable smp-server apps/smp-server default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -480,7 +480,7 @@ executable xftp apps/xftp default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -555,7 +555,7 @@ executable xftp-server apps/xftp-server default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -662,7 +662,7 @@ test-suite simplexmq-test tests default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts -with-rtsopts=-A64M -with-rtsopts=-N1 + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts -with-rtsopts=-A64M -with-rtsopts=-N1 build-depends: HUnit ==1.6.* , QuickCheck ==2.14.* diff --git a/src/Simplex/FileTransfer/Agent.hs b/src/Simplex/FileTransfer/Agent.hs index d04117942..415ead6c0 100644 --- a/src/Simplex/FileTransfer/Agent.hs +++ b/src/Simplex/FileTransfer/Agent.hs @@ -262,9 +262,9 @@ runXFTPRcvLocalWorker c Worker {doWork} = do withStore' c $ \db -> updateRcvFileStatus db rcvFileId RFSDecrypting chunkPaths <- getChunkPaths chunks encSize <- liftIO $ foldM (\s path -> (s +) . fromIntegral <$> getFileSize path) 0 chunkPaths - when (FileSize encSize /= size) $ throwError $ XFTP XFTP.SIZE + when (FileSize encSize /= size) $ throwError $ XFTP "" XFTP.SIZE encDigest <- liftIO $ LC.sha512Hash <$> readChunks chunkPaths - when (FileDigest encDigest /= digest) $ throwError $ XFTP XFTP.DIGEST + when (FileDigest encDigest /= digest) $ throwError $ XFTP "" XFTP.DIGEST let destFile = CryptoFile fsSavePath cfArgs void $ liftError (INTERNAL . show) $ decryptChunks encSize chunkPaths key nonce $ \_ -> pure destFile case redirect of @@ -281,10 +281,11 @@ runXFTPRcvLocalWorker c Worker {doWork} = do -- proceed with redirect yaml <- liftError (INTERNAL . show) (CF.readFile $ CryptoFile fsSavePath cfArgs) `agentFinally` (lift $ toFSFilePath fsSavePath >>= removePath) next@FileDescription {chunks = nextChunks} <- case strDecode (LB.toStrict yaml) of - Left _ -> throwError . XFTP $ XFTP.REDIRECT "decode error" + -- TODO switch to another error constructor + Left _ -> throwError . XFTP "" $ XFTP.REDIRECT "decode error" Right (ValidFileDescription fd@FileDescription {size = dstSize, digest = dstDigest}) - | dstSize /= redirectSize -> throwError . XFTP $ XFTP.REDIRECT "size mismatch" - | dstDigest /= redirectDigest -> throwError . XFTP $ XFTP.REDIRECT "digest mismatch" + | dstSize /= redirectSize -> throwError . XFTP "" $ XFTP.REDIRECT "size mismatch" + | dstDigest /= redirectDigest -> throwError . XFTP "" $ XFTP.REDIRECT "digest mismatch" | otherwise -> pure fd -- register and download chunks from the actual file withStore c $ \db -> updateRcvFileRedirect db redirectDbId next diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index f93d03e35..f834927d8 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -158,7 +158,7 @@ import Simplex.Messaging.Agent.Store import Simplex.Messaging.Agent.Store.SQLite import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Client (ProtocolClient (..), ServerTransmission) +import Simplex.Messaging.Client (ProtocolClient (..), ServerTransmission, TransmissionType (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile, CryptoFileArgs) import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) @@ -734,11 +734,9 @@ newRcvConnSrv c userId connId enableNtfs cMode clientData pqInitKeys subMode srv (SCMContact, CR.IKUsePQ) -> throwError $ CMD PROHIBITED _ -> pure () AgentConfig {smpClientVRange, smpAgentVRange, e2eEncryptVRange} <- asks config - (rq, qUri) <- newRcvQueue c userId connId srv smpClientVRange subMode `catchAgentError` \e -> liftIO (print e) >> throwError e + (rq, qUri, tSess, sessId) <- newRcvQueue c userId connId srv smpClientVRange subMode `catchAgentError` \e -> liftIO (print e) >> throwError e rq' <- withStore c $ \db -> updateNewConnRcv db connId rq - liftIO $ case subMode of - SMOnlyCreate -> pure () - SMSubscribe -> addSubscription c rq' + lift . when (subMode == SMSubscribe) $ addNewQueueSubscription c rq' tSess sessId when enableNtfs $ do ns <- asks ntfSupervisor atomically $ sendNtfSubCommand ns (connId, NSCCreate) @@ -863,12 +861,10 @@ joinConnSrvAsync _c _userId _connId _enableNtfs (CRContactUri _) _cInfo _subMode createReplyQueue :: AgentClient -> ConnData -> SndQueue -> SubscriptionMode -> SMPServerWithAuth -> AM SMPQueueInfo createReplyQueue c ConnData {userId, connId, enableNtfs} SndQueue {smpClientVersion} subMode srv = do - (rq, qUri) <- newRcvQueue c userId connId srv (versionToRange smpClientVersion) subMode + (rq, qUri, tSess, sessId) <- newRcvQueue c userId connId srv (versionToRange smpClientVersion) subMode let qInfo = toVersionT qUri smpClientVersion rq' <- withStore c $ \db -> upgradeSndConnToDuplex db connId rq - liftIO $ case subMode of - SMOnlyCreate -> pure () - SMSubscribe -> addSubscription c rq' + lift . when (subMode == SMSubscribe) $ addNewQueueSubscription c rq' tSess sessId when enableNtfs $ do ns <- asks ntfSupervisor atomically $ sendNtfSubCommand ns (connId, NSCCreate) @@ -928,7 +924,7 @@ subscribeConnections' c connIds = do (subRs, rcvQs) = M.mapEither rcvQueueOrResult cs mapM_ (mapM_ (\(cData, sqs) -> mapM_ (lift . resumeMsgDelivery c cData) sqs) . sndQueue) cs mapM_ (resumeConnCmds c) $ M.keys cs - rcvRs <- lift $ connResults <$> subscribeQueues c (concat $ M.elems rcvQs) + rcvRs <- lift $ connResults . fst <$> subscribeQueues c (concat $ M.elems rcvQs) ns <- asks ntfSupervisor tkn <- readTVarIO (ntfTkn ns) when (instantNotifications tkn) . void . lift . forkIO . void . runExceptT $ sendNtfCreate ns rcvRs conns @@ -1326,13 +1322,13 @@ runSmpQueueMsgDelivery c@AgentClient {subQ} ConnData {connId} sq (Worker {doWork Left e -> do let err = if msgType == AM_A_MSG_ then MERR mId e else ERR e case e of - SMP SMP.QUOTA -> case msgType of + SMP _ SMP.QUOTA -> case msgType of AM_CONN_INFO -> connError msgId NOT_AVAILABLE AM_CONN_INFO_REPLY -> connError msgId NOT_AVAILABLE _ -> do expireTs <- addUTCTime (-quotaExceededTimeout) <$> liftIO getCurrentTime if internalTs < expireTs then notifyDelMsgs msgId e expireTs else retrySndMsg RISlow - SMP SMP.AUTH -> case msgType of + SMP _ SMP.AUTH -> case msgType of AM_CONN_INFO -> connError msgId NOT_AVAILABLE AM_CONN_INFO_REPLY -> connError msgId NOT_AVAILABLE AM_RATCHET_INFO -> connError msgId NOT_AVAILABLE @@ -1508,10 +1504,10 @@ switchDuplexConnection c (DuplexConnection cData@ConnData {connId, userId} rqs s -- try to get the server that is different from all queues, or at least from the primary rcv queue srvAuth@(ProtoServerWithAuth srv _) <- getNextServer c userId $ map qServer (L.toList rqs) <> map qServer (L.toList sqs) srv' <- if srv == server then getNextServer c userId [server] else pure srvAuth - (q, qUri) <- newRcvQueue c userId connId srv' clientVRange SMSubscribe + (q, qUri, tSess, sessId) <- newRcvQueue c userId connId srv' clientVRange SMSubscribe let rq' = (q :: NewRcvQueue) {primary = True, dbReplaceQueueId = Just dbQueueId} rq'' <- withStore c $ \db -> addConnRcvQueue db connId rq' - liftIO $ addSubscription c rq'' + lift $ addNewQueueSubscription c rq'' tSess sessId void . enqueueMessages c cData sqs SMP.noMsgFlags $ QADD [(qUri, Just (server, sndId))] rq1 <- withStore' c $ \db -> setRcvSwitchStatus db rq $ Just RSSendingQADD let rqs' = updatedQs rq1 rqs <> [rq''] @@ -1565,7 +1561,7 @@ synchronizeRatchet' c connId pqSupport' force = withConnLock c connId "synchroni ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM () ackQueueMessage c rq srvMsgId = sendAck c rq srvMsgId `catchAgentError` \case - SMP SMP.NO_MSG -> pure () + SMP _ SMP.NO_MSG -> pure () e -> throwError e -- | Suspend SMP agent connection (OFF command) in Reader monad @@ -1895,7 +1891,7 @@ deleteToken_ c tkn@NtfToken {ntfTokenId, ntfTknStatus} = do withStore' c $ \db -> updateNtfToken db tkn ntfTknStatus ntfTknAction atomically $ nsUpdateToken ns tkn {ntfTknStatus, ntfTknAction} agentNtfDeleteToken c tknId tkn `catchAgentError` \case - NTF AUTH -> pure () + NTF _ AUTH -> pure () e -> throwError e withStore' c $ \db -> removeNtfToken db tkn atomically $ nsRemoveNtfToken ns @@ -1912,7 +1908,7 @@ withToken c tkn@NtfToken {deviceToken, ntfMode} from_ (toStatus, toAction_) f = let updatedToken = tkn {ntfTknStatus = toStatus, ntfTknAction = toAction_} atomically $ nsUpdateToken ns updatedToken pure toStatus - Left e@(NTF AUTH) -> do + Left e@(NTF _ AUTH) -> do withStore' c $ \db -> removeNtfToken db tkn atomically $ nsRemoveNtfToken ns void $ registerNtfToken' c deviceToken ntfMode @@ -1995,11 +1991,13 @@ getSMPServer c userId = withUserServers c userId pickServer {-# INLINE getSMPServer #-} subscriber :: AgentClient -> AM' () -subscriber c@AgentClient {msgQ} = forever $ do +subscriber c@AgentClient {subQ, msgQ} = forever $ do t <- atomically $ readTBQueue msgQ agentOperationBracket c AORcvNetwork waitUntilActive $ runExceptT (processSMPTransmission c t) >>= \case - Left e -> liftIO $ print e + Left e -> do + logError $ tshow e + atomically $ writeTBQueue subQ ("", "", APC SAEConn $ ERR e) Right _ -> return () cleanupManager :: AgentClient -> AM' () @@ -2076,8 +2074,8 @@ data ACKd = ACKd | ACKPending -- | make sure to ACK or throw in each message processing branch -- it cannot be finally, unfortunately, as sometimes it needs to be ACK+DEL -processSMPTransmission :: AgentClient -> ServerTransmission SMPVersion BrokerMsg -> AM () -processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, sessId, isResponse, rId, cmd) = do +processSMPTransmission :: AgentClient -> ServerTransmission SMPVersion ErrorType BrokerMsg -> AM () +processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, sessId, tType, rId, cmd) = do (rq, SomeConn _ conn) <- withStore c (\db -> getRcvConn db srv rId) processSMP rq conn $ toConnData conn where @@ -2087,14 +2085,15 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, conn cData@ConnData {userId, connId, connAgentVersion, ratchetSyncState = rss} = withConnLock c connId "processSMP" $ case cmd of - SMP.MSG msg@SMP.RcvMessage {msgId = srvMsgId} -> + Right (SMP.MSG msg@SMP.RcvMessage {msgId = srvMsgId}) -> void . handleNotifyAck $ do + isGET <- atomically $ hasGetLock c rq + unless isGET checkExpiredResponse msg' <- decryptSMPMessage rq msg ack' <- handleNotifyAck $ case msg' of SMP.ClientRcvMsgBody {msgTs = srvTs, msgFlags, msgBody} -> processClientMsg srvTs msgFlags msgBody SMP.ClientRcvMsgQuota {} -> queueDrained >> ack - whenM (atomically $ hasGetLock c rq) $ - notify (MSGNTF $ SMP.rcvMessageMeta srvMsgId msg') + when isGET $ notify (MSGNTF $ SMP.rcvMessageMeta srvMsgId msg') pure ack' where queueDrained = case conn of @@ -2237,7 +2236,7 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, ackDel aId = enqueueCmd (ICAckDel rId srvMsgId aId) $> ACKd handleNotifyAck :: AM ACKd -> AM ACKd handleNotifyAck m = m `catchAgentError` \e -> notify (ERR e) >> ack - SMP.END -> + Right SMP.END -> atomically (TM.lookup tSess smpClients $>>= (tryReadTMVar . sessionVar) >>= processEND) >>= logServer "<--" c srv rId where @@ -2250,9 +2249,10 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, | otherwise -> ignored _ -> ignored ignored = pure "END from disconnected client - ignored" - _ -> do - logServer "<--" c srv rId $ "unexpected: " <> bshow cmd - notify . ERR $ BROKER (B.unpack $ strEncode srv) $ if isResponse then TIMEOUT else UNEXPECTED + Right (SMP.ERR e) -> notify $ ERR $ SMP (B.unpack $ strEncode srv) e + Right SMP.OK -> checkExpiredResponse + Right _ -> unexpected + Left e -> notify $ ERR $ protocolClientError SMP (B.unpack $ strEncode srv) e where notify :: forall e m. MonadIO m => AEntityI e => ACommand 'Agent e -> m () notify = atomically . notify' @@ -2266,6 +2266,27 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, enqueueCmd :: InternalCommand -> AM () enqueueCmd = enqueueCommand c "" connId (Just srv) . AInternalCommand + unexpected :: AM () + unexpected = do + logServer "<--" c srv rId $ "unexpected: " <> bshow cmd + -- TODO add extended information about transmission type once UNEXPECTED has string + notify . ERR $ BROKER (B.unpack $ strEncode srv) UNEXPECTED + + checkExpiredResponse :: AM () + checkExpiredResponse = case tType of + TTEvent -> pure () + TTUncorrelatedResponse -> unexpected + TTExpiredResponse (SMP.Cmd _ cmd') -> case cmd' of + SMP.SUB -> do + added <- + atomically $ + ifM + ((&&) <$> hasPendingSubscription c connId <*> activeClientSession c tSess sessId) + (True <$ addSubscription c rq) + (pure False) + when added $ notify $ UP srv [connId] + _ -> pure () + decryptClientMessage :: C.DhSecretX25519 -> SMP.ClientMsgEnvelope -> AM (SMP.PrivHeader, AgentMsgEnvelope) decryptClientMessage e2eDh SMP.ClientMsgEnvelope {cmNonce, cmEncBody} = do clientMsg <- agentCbDecrypt e2eDh cmNonce cmEncBody diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index cfed6b932..d9b059d59 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -41,6 +41,7 @@ module Simplex.Messaging.Agent.Client getQueueMessage, decryptSMPMessage, addSubscription, + addNewQueueSubscription, getSubscriptions, sendConfirmation, sendInvitation, @@ -77,11 +78,14 @@ module Simplex.Messaging.Agent.Client logSecret, removeSubscription, hasActiveSubscription, + hasPendingSubscription, hasGetLock, + activeClientSession, agentClientStore, agentDRG, getAgentSubscriptions, slowNetworkConfig, + protocolClientError, Worker (..), SessionVar (..), SubscriptionsInfo (..), @@ -152,7 +156,7 @@ import Data.Bifunctor (bimap, first, second) import Data.ByteString.Base64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Either (lefts, partitionEithers) +import Data.Either (partitionEithers) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (deleteFirstsBy, foldl', partition, (\\)) @@ -229,7 +233,7 @@ import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Transport (SMPVersion) +import Simplex.Messaging.Transport (SMPVersion, SessionId, THandleParams (sessionId)) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util import Simplex.Messaging.Version @@ -260,7 +264,7 @@ data AgentClient = AgentClient active :: TVar Bool, rcvQ :: TBQueue (ATransmission 'Client), subQ :: TBQueue (ATransmission 'Agent), - msgQ :: TBQueue (ServerTransmission SMPVersion BrokerMsg), + msgQ :: TBQueue (ServerTransmission SMPVersion ErrorType BrokerMsg), smpServers :: TMap UserId (NonEmpty SMPServerWithAuth), smpClients :: TMap SMPTransportSession SMPClientVar, ntfServers :: TVar [NtfServer], @@ -511,7 +515,7 @@ agentDRG AgentClient {agentEnv = Env {random}} = random class (Encoding err, Show err) => ProtocolServerClient v err msg | msg -> v, msg -> err where type Client msg = c | c -> msg getProtocolServerClient :: AgentClient -> TransportSession msg -> AM (Client msg) - clientProtocolError :: err -> AgentErrorType + clientProtocolError :: HostName -> err -> AgentErrorType closeProtocolServerClient :: Client msg -> IO () clientServer :: Client msg -> String clientTransportHost :: Client msg -> TransportHost @@ -644,7 +648,7 @@ reconnectSMPClient tc c tSess@(_, srv, _) qs = do resubscribe :: AM () resubscribe = do cs <- readTVarIO $ RQ.getConnections $ activeSubs c - rs <- lift . subscribeQueues c $ L.toList qs + (rs, sessId_) <- lift . subscribeQueues c $ L.toList qs let (errs, okConns) = partitionEithers $ map (\(RcvQueue {connId}, r) -> bimap (connId,) (const connId) r) rs liftIO $ do let conns = filter (`M.notMember` cs) okConns @@ -653,7 +657,10 @@ reconnectSMPClient tc c tSess@(_, srv, _) qs = do liftIO $ mapM_ (\(connId, e) -> notifySub connId $ ERR e) finalErrs forM_ (listToMaybe tempErrs) $ \(_, err) -> do when (null okConns && M.null cs && null finalErrs) . liftIO $ - closeClient c smpClients tSess + forM_ sessId_ $ \sessId -> do + -- We only close the client session that was used to subscribe. + v_ <- atomically $ ifM (activeClientSession c tSess sessId) (TM.lookupDelete tSess $ smpClients c) (pure Nothing) + mapM_ (closeClient_ c) v_ throwError err notifySub :: forall e. AEntityI e => ConnId -> ACommand 'Agent e -> IO () notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) @@ -938,13 +945,13 @@ withXFTPClient c (userId, srv, entityId) cmdStr action = do tSess <- liftIO $ mkTransportSession c userId srv entityId withLogClient c tSess entityId cmdStr action -liftClient :: (Show err, Encoding err) => (err -> AgentErrorType) -> HostName -> ExceptT (ProtocolClientError err) IO a -> AM a +liftClient :: (Show err, Encoding err) => (HostName -> err -> AgentErrorType) -> HostName -> ExceptT (ProtocolClientError err) IO a -> AM a liftClient protocolError_ = liftError . protocolClientError protocolError_ {-# INLINE liftClient #-} -protocolClientError :: (Show err, Encoding err) => (err -> AgentErrorType) -> HostName -> ProtocolClientError err -> AgentErrorType +protocolClientError :: (Show err, Encoding err) => (HostName -> err -> AgentErrorType) -> HostName -> ProtocolClientError err -> AgentErrorType protocolClientError protocolError_ host = \case - PCEProtocolError e -> protocolError_ e + PCEProtocolError e -> protocolError_ host e PCEResponseError e -> BROKER host $ RESPONSE $ B.unpack $ smpEncode e PCEUnexpectedResponse _ -> BROKER host UNEXPECTED PCEResponseTimeout -> BROKER host TIMEOUT @@ -1023,7 +1030,7 @@ runXFTPServerTest c userId (ProtoServerWithAuth srv auth) = do liftError (testErr TSUploadFile) $ X.uploadXFTPChunk xftp spKey sId chunkSpec liftError (testErr TSDownloadFile) $ X.downloadXFTPChunk g xftp rpKey rId $ XFTPRcvChunkSpec rcvPath chSize digest rcvDigest <- liftIO $ C.sha256Hash <$> B.readFile rcvPath - unless (digest == rcvDigest) $ throwError $ ProtocolTestFailure TSCompareFile $ XFTP DIGEST + unless (digest == rcvDigest) $ throwError $ ProtocolTestFailure TSCompareFile $ XFTP (B.unpack $ strEncode srv) DIGEST liftError (testErr TSDeleteFile) $ X.deleteXFTPChunk xftp spKey sId ok <- tcpTimeout xftpNetworkConfig `timeout` X.closeXFTPClient xftp incClientStat c userId xftp "XFTP_TEST" "OK" @@ -1098,7 +1105,7 @@ getSessionMode :: AgentClient -> IO TransportSessionMode getSessionMode = atomically . fmap sessionMode . getNetworkConfig {-# INLINE getSessionMode #-} -newRcvQueue :: AgentClient -> UserId -> ConnId -> SMPServerWithAuth -> VersionRangeSMPC -> SubscriptionMode -> AM (NewRcvQueue, SMPQueueUri) +newRcvQueue :: AgentClient -> UserId -> ConnId -> SMPServerWithAuth -> VersionRangeSMPC -> SubscriptionMode -> AM (NewRcvQueue, SMPQueueUri, SMPTransportSession, SessionId) newRcvQueue c userId connId (ProtoServerWithAuth srv auth) vRange subMode = do C.AuthAlg a <- asks (rcvAuthAlg . config) g <- asks random @@ -1107,8 +1114,9 @@ newRcvQueue c userId connId (ProtoServerWithAuth srv auth) vRange subMode = do (e2eDhKey, e2ePrivKey) <- atomically $ C.generateKeyPair g logServer "-->" c srv "" "NEW" tSess <- liftIO $ mkTransportSession c userId srv connId - QIK {rcvId, sndId, rcvPublicDhKey} <- - withClient c tSess "NEW" $ \smp -> createSMPQueue smp rKeys dhKey auth subMode + (sessId, QIK {rcvId, sndId, rcvPublicDhKey}) <- + withClient c tSess "NEW" $ \smp -> + (sessionId $ thParams smp,) <$> createSMPQueue smp rKeys dhKey auth subMode liftIO . logServer "<--" c srv "" $ B.unwords ["IDS", logSecret rcvId, logSecret sndId] let rq = RcvQueue @@ -1130,17 +1138,18 @@ newRcvQueue c userId connId (ProtoServerWithAuth srv auth) vRange subMode = do clientNtfCreds = Nothing, deleteErrors = 0 } - pure (rq, SMPQueueUri vRange $ SMPQueueAddress srv sndId e2eDhKey) + qUri = SMPQueueUri vRange $ SMPQueueAddress srv sndId e2eDhKey + pure (rq, qUri, tSess, sessId) -processSubResult :: AgentClient -> RcvQueue -> Either SMPClientError () -> IO (Either SMPClientError ()) -processSubResult c rq r = do - case r of - Left e -> - unless (temporaryClientError e) . atomically $ do - RQ.deleteQueue rq (pendingSubs c) - TM.insert (RQ.qKey rq) e (removedSubs c) - _ -> addSubscription c rq - pure r +processSubResult :: AgentClient -> RcvQueue -> Either SMPClientError () -> STM () +processSubResult c rq@RcvQueue {connId} = \case + Left e -> + unless (temporaryClientError e) $ do + RQ.deleteQueue rq (pendingSubs c) + TM.insert (RQ.qKey rq) e (removedSubs c) + Right () -> + whenM (hasPendingSubscription c connId) $ + addSubscription c rq temporaryAgentError :: AgentErrorType -> Bool temporaryAgentError = \case @@ -1157,7 +1166,7 @@ temporaryOrHostError = \case {-# INLINE temporaryOrHostError #-} -- | Subscribe to queues. The list of results can have a different order. -subscribeQueues :: AgentClient -> [RcvQueue] -> AM' [(RcvQueue, Either AgentErrorType ())] +subscribeQueues :: AgentClient -> [RcvQueue] -> AM' ([(RcvQueue, Either AgentErrorType ())], Maybe SessionId) subscribeQueues c qs = do (errs, qs') <- partitionEithers <$> mapM checkQueue qs atomically $ do @@ -1165,20 +1174,43 @@ subscribeQueues c qs = do RQ.batchAddQueues (pendingSubs c) qs' env <- ask -- only "checked" queues are subscribed - (errs <>) <$> sendTSessionBatches "SUB" 90 id (subscribeQueues_ env) c qs' + session <- newTVarIO Nothing + rs <- sendTSessionBatches "SUB" 90 id (subscribeQueues_ env session) c qs' + (errs <> rs,) <$> readTVarIO session where checkQueue rq = do prohibited <- atomically $ hasGetLock c rq pure $ if prohibited then Left (rq, Left $ CMD PROHIBITED) else Right rq - subscribeQueues_ :: Env -> SMPClient -> NonEmpty RcvQueue -> IO (BatchResponses SMPClientError ()) - subscribeQueues_ env smp qs' = do + subscribeQueues_ :: Env -> TVar (Maybe SessionId) -> SMPClient -> NonEmpty RcvQueue -> IO (BatchResponses SMPClientError ()) + subscribeQueues_ env session smp qs' = do rs <- sendBatch subscribeSMPQueues smp qs' - mapM_ (uncurry $ processSubResult c) rs - when (any temporaryClientError . lefts . map snd $ L.toList rs) $ - runReaderT (resubscribeSMPSession c $ transportSession' smp) env - pure rs + active <- + atomically $ + ifM + (activeClientSession c tSess sessId) + (writeTVar session (Just sessId) >> processSubResults rs $> True) + (pure False) + if active + then when (hasTempErrors rs) resubscribe $> rs + else do + logWarn "subcription batch result for replaced SMP client, resubscribing" + resubscribe $> L.map (second $ \_ -> Left PCENetworkError) rs + where + tSess = transportSession' smp + sessId = sessionId $ thParams smp + hasTempErrors = any (either temporaryClientError (const False) . snd) + processSubResults :: NonEmpty (RcvQueue, Either SMPClientError ()) -> STM () + processSubResults = mapM_ $ uncurry $ processSubResult c + resubscribe = resubscribeSMPSession c tSess `runReaderT` env -type BatchResponses e r = (NonEmpty (RcvQueue, Either e r)) +activeClientSession :: AgentClient -> SMPTransportSession -> SessionId -> STM Bool +activeClientSession c tSess sessId = sameSess <$> tryReadSessVar tSess (smpClients c) + where + sameSess = \case + Just (Right smp) -> sessId == sessionId (thParams smp) + _ -> False + +type BatchResponses e r = NonEmpty (RcvQueue, Either e r) -- statBatchSize is not used to batch the commands, only for traffic statistics sendTSessionBatches :: forall q r. ByteString -> Int -> (q -> RcvQueue) -> (SMPClient -> NonEmpty q -> IO (BatchResponses SMPClientError r)) -> AgentClient -> [q] -> AM' [(RcvQueue, Either AgentErrorType r)] @@ -1213,16 +1245,35 @@ sendBatch smpCmdFunc smp qs = L.zip qs <$> smpCmdFunc smp (L.map queueCreds qs) where queueCreds RcvQueue {rcvPrivateKey, rcvId} = (rcvPrivateKey, rcvId) -addSubscription :: AgentClient -> RcvQueue -> IO () -addSubscription c rq@RcvQueue {connId} = atomically $ do +addSubscription :: AgentClient -> RcvQueue -> STM () +addSubscription c rq@RcvQueue {connId} = do modifyTVar' (subscrConns c) $ S.insert connId RQ.addQueue rq $ activeSubs c RQ.deleteQueue rq $ pendingSubs c +addPendingSubscription :: AgentClient -> RcvQueue -> STM () +addPendingSubscription c rq@RcvQueue {connId} = do + modifyTVar' (subscrConns c) $ S.insert connId + RQ.addQueue rq $ pendingSubs c + +addNewQueueSubscription :: AgentClient -> RcvQueue -> SMPTransportSession -> SessionId -> AM' () +addNewQueueSubscription c rq tSess sessId = do + same <- + atomically $ + ifM + (activeClientSession c tSess sessId) + (True <$ addSubscription c rq) + (False <$ addPendingSubscription c rq) + unless same $ resubscribeSMPSession c tSess + hasActiveSubscription :: AgentClient -> ConnId -> STM Bool hasActiveSubscription c connId = RQ.hasConn connId $ activeSubs c {-# INLINE hasActiveSubscription #-} +hasPendingSubscription :: AgentClient -> ConnId -> STM Bool +hasPendingSubscription c connId = RQ.hasConn connId $ pendingSubs c +{-# INLINE hasPendingSubscription #-} + removeSubscription :: AgentClient -> ConnId -> STM () removeSubscription c connId = do modifyTVar' (subscrConns c) $ S.delete connId diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 98db26ab4..e136a8bbb 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -193,13 +193,13 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet ( InitialKeys (..), PQEncryption (..), - pattern PQEncOff, PQSupport, - pattern PQSupportOn, - pattern PQSupportOff, RcvE2ERatchetParams, RcvE2ERatchetParamsUri, - SndE2ERatchetParams + SndE2ERatchetParams, + pattern PQEncOff, + pattern PQSupportOff, + pattern PQSupportOn, ) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -213,14 +213,14 @@ import Simplex.Messaging.Protocol MsgId, NMsgMeta, ProtocolServer (..), + SMPClientVersion, SMPMsgMeta, SMPServer, SMPServerWithAuth, SndPublicAuthKey, SubscriptionMode, - SMPClientVersion, - VersionSMPC, VersionRangeSMPC, + VersionSMPC, initialSMPClientVersion, legacyEncodeServer, legacyServerP, @@ -908,7 +908,7 @@ instance Encoding AgentMsgEnvelope where -- AgentRatchetInfo is not encrypted with double ratchet, but with per-queue E2E encryption data AgentMessage = -- used by the initiating party when confirming reply queue - AgentConnInfo ConnInfo + AgentConnInfo ConnInfo | -- AgentConnInfoReply is used by accepting party in duplexHandshake mode (v2), allowing to include reply queue(s) in the initial confirmation. -- It made removed REPLY message unnecessary. AgentConnInfoReply (NonEmpty SMPQueueInfo) ConnInfo @@ -1382,9 +1382,9 @@ deriving instance Show (ConnectionRequestUri m) data AConnectionRequestUri = forall m. ConnectionModeI m => ACR (SConnectionMode m) (ConnectionRequestUri m) instance Eq AConnectionRequestUri where - ACR m cr == ACR m' cr' = case testEquality m m' of - Just Refl -> cr == cr' - _ -> False + ACR m cr == ACR m' cr' = case testEquality m m' of + Just Refl -> cr == cr' + _ -> False deriving instance Show AConnectionRequestUri @@ -1469,11 +1469,11 @@ data AgentErrorType | -- | connection errors CONN {connErr :: ConnectionErrorType} | -- | SMP protocol errors forwarded to agent clients - SMP {smpErr :: ErrorType} + SMP {serverAddress :: String, smpErr :: ErrorType} | -- | NTF protocol errors forwarded to agent clients - NTF {ntfErr :: ErrorType} + NTF {serverAddress :: String, ntfErr :: ErrorType} | -- | XFTP protocol errors forwarded to agent clients - XFTP {xftpErr :: XFTPErrorType} + XFTP {serverAddress :: String, xftpErr :: XFTPErrorType} | -- | XRCP protocol errors forwarded to agent clients RCP {rcpErr :: RCErrorType} | -- | SMP server errors @@ -1584,9 +1584,9 @@ instance StrEncoding AgentErrorType where strP = "CMD " *> (CMD <$> parseRead1) <|> "CONN " *> (CONN <$> parseRead1) - <|> "SMP " *> (SMP <$> strP) - <|> "NTF " *> (NTF <$> strP) - <|> "XFTP " *> (XFTP <$> strP) + <|> "SMP " *> (SMP <$> textP <*> _strP) + <|> "NTF " *> (NTF <$> textP <*> _strP) + <|> "XFTP " *> (XFTP <$> textP <*> _strP) <|> "RCP " *> (RCP <$> strP) <|> "BROKER " *> (BROKER <$> textP <* " RESPONSE " <*> (RESPONSE <$> textP)) <|> "BROKER " *> (BROKER <$> textP <* " TRANSPORT " <*> (TRANSPORT <$> transportErrorP)) @@ -1602,9 +1602,9 @@ instance StrEncoding AgentErrorType where strEncode = \case CMD e -> "CMD " <> bshow e CONN e -> "CONN " <> bshow e - SMP e -> "SMP " <> strEncode e - NTF e -> "NTF " <> strEncode e - XFTP e -> "XFTP " <> strEncode e + SMP srv e -> "SMP " <> text srv <> " " <> strEncode e + NTF srv e -> "NTF " <> text srv <> " " <> strEncode e + XFTP srv e -> "XFTP " <> text srv <> " " <> strEncode e RCP e -> "RCP " <> strEncode e BROKER srv (RESPONSE e) -> "BROKER " <> text srv <> " RESPONSE " <> text e BROKER srv (TRANSPORT e) -> "BROKER " <> text srv <> " TRANSPORT " <> serializeTransportError e diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 176602f4b..6cee90839 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -70,6 +70,7 @@ module Simplex.Messaging.Client proxyUsername, temporaryClientError, ServerTransmission, + TransmissionType (..), ClientCommand, -- * For testing @@ -80,9 +81,11 @@ module Simplex.Messaging.Client ) where +import Control.Applicative ((<|>)) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class (liftIO) @@ -110,7 +113,7 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (SocksProxy, TransportClientConfig (..), TransportHost (..), defaultTcpConnectTimeout, runTransportClient) import Simplex.Messaging.Transport.KeepAlive import Simplex.Messaging.Transport.WebSockets (WS) -import Simplex.Messaging.Util (bshow, diffToMicroseconds, raceAny_, threadDelay', whenM) +import Simplex.Messaging.Util (bshow, diffToMicroseconds, ifM, raceAny_, threadDelay', tshow, whenM) import Simplex.Messaging.Version import System.Timeout (timeout) @@ -129,15 +132,14 @@ data PClient v err msg = PClient transportSession :: TransportSession msg, transportHost :: TransportHost, tcpTimeout :: Int, - rcvConcurrency :: Int, sendPings :: TVar Bool, lastReceived :: TVar UTCTime, timeoutErrorCount :: TVar Int, clientCorrId :: TVar ChaChaDRG, sentCommands :: TMap CorrId (Request err msg), - sndQ :: TBQueue (TVar Bool, ByteString), + sndQ :: TBQueue ByteString, rcvQ :: TBQueue (NonEmpty (SignedTransmission err msg)), - msgQ :: Maybe (TBQueue (ServerTransmission v msg)) + msgQ :: Maybe (TBQueue (ServerTransmission v err msg)) } smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe (THandleAuth 'TClient) -> STM SMPClient @@ -170,7 +172,6 @@ smpClientStub g sessionId thVersion thAuth = do transportSession = (1, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001", Nothing), transportHost = "localhost", tcpTimeout = 15_000_000, - rcvConcurrency = 8, sendPings, lastReceived, timeoutErrorCount, @@ -188,7 +189,9 @@ type SMPClient = ProtocolClient SMPVersion ErrorType BrokerMsg type ClientCommand msg = (Maybe C.APrivateAuthKey, EntityId, ProtoCommand msg) -- | Type synonym for transmission from some SPM server queue. -type ServerTransmission v msg = (TransportSession msg, Version v, SessionId, Bool, EntityId, msg) +type ServerTransmission v err msg = (TransportSession msg, Version v, SessionId, TransmissionType msg, EntityId, Either (ProtocolClientError err) msg) + +data TransmissionType msg = TTEvent | TTUncorrelatedResponse | TTExpiredResponse (ProtoCommand msg) data HostMode = -- | prefer (or require) onion hosts when connecting via SOCKS proxy @@ -287,6 +290,8 @@ defaultSMPClientConfig = defaultClientConfig (Just supportedSMPHandshakes) suppo data Request err msg = Request { corrId :: CorrId, entityId :: EntityId, + command :: ProtoCommand msg, + pending :: TVar Bool, responseVar :: TMVar (Either (ProtocolClientError err) msg) } @@ -333,7 +338,7 @@ type TransportSession msg = (UserId, ProtoServer msg, Maybe EntityId) -- -- A single queue can be used for multiple 'SMPClient' instances, -- as 'SMPServerTransmission' includes server information. -getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> TransportSession msg -> ProtocolClientConfig v -> Maybe (TBQueue (ServerTransmission v msg)) -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) +getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> TransportSession msg -> ProtocolClientConfig v -> Maybe (TBQueue (ServerTransmission v err msg)) -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, clientALPN, serverVRange, agreeSecret} msgQ disconnected = do case chooseTransportHost networkConfig (host srv) of Right useHost -> @@ -341,7 +346,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize `catch` \(e :: IOException) -> pure . Left $ PCEIOError e Left e -> pure $ Left e where - NetworkConfig {tcpConnectTimeout, tcpTimeout, rcvConcurrency, smpPingInterval} = networkConfig + NetworkConfig {tcpConnectTimeout, tcpTimeout, smpPingInterval} = networkConfig mkProtocolClient :: TransportHost -> UTCTime -> STM (PClient v err msg) mkProtocolClient transportHost ts = do connected <- newTVar False @@ -363,7 +368,6 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize timeoutErrorCount, clientCorrId, sentCommands, - rcvConcurrency, sndQ, rcvQ, msgQ @@ -402,11 +406,11 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize atomically $ do writeTVar (connected c) True putTMVar cVar $ Right c' - raceAny_ ([send c' th, process c', receive c' th] <> [ping c' | smpPingInterval > 0]) + raceAny_ ([send c' th, process c', receive c' th] <> [monitor c' | smpPingInterval > 0]) `finally` disconnected c' send :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () - send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= \(active, s) -> whenM (readTVarIO active) (void $ tPutLog h s) + send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= void . tPutLog h receive :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () receive ProtocolClient {client_ = PClient {rcvQ, lastReceived, timeoutErrorCount}} h = forever $ do @@ -414,8 +418,8 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize getCurrentTime >>= atomically . writeTVar lastReceived atomically $ writeTVar timeoutErrorCount 0 - ping :: ProtocolClient v err msg -> IO () - ping c@ProtocolClient {client_ = PClient {sendPings, lastReceived, timeoutErrorCount}} = loop smpPingInterval + monitor :: ProtocolClient v err msg -> IO () + monitor c@ProtocolClient {client_ = PClient {sendPings, lastReceived, timeoutErrorCount}} = loop smpPingInterval where loop :: Int64 -> IO () loop delay = do @@ -439,27 +443,34 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize processMsg :: ProtocolClient v err msg -> SignedTransmission err msg -> IO () processMsg c@ProtocolClient {client_ = PClient {sentCommands}} (_, _, (corrId, entId, respOrErr)) - | isResponse = + | not $ B.null $ bs corrId = atomically (TM.lookup corrId sentCommands) >>= \case - Nothing -> sendMsg respOrErr - Just Request {entityId, responseVar} -> atomically $ do - TM.delete corrId sentCommands - putTMVar responseVar $ response entityId - | otherwise = sendMsg respOrErr + Nothing -> sendMsg TTUncorrelatedResponse + Just Request {entityId, command, pending, responseVar} -> do + wasPending <- + atomically $ do + TM.delete corrId sentCommands + ifM + (swapTVar pending False) + (True <$ tryPutTMVar responseVar (response entityId)) + (pure False) + unless wasPending $ sendMsg $ if entityId == entId then TTExpiredResponse command else TTUncorrelatedResponse + | otherwise = sendMsg TTEvent where - isResponse = not $ B.null $ bs corrId response entityId - | entityId == entId = - case respOrErr of - Left e -> Left $ PCEResponseError e - Right r -> case protocolError r of - Just e -> Left $ PCEProtocolError e - _ -> Right r + | entityId == entId = clientResp | otherwise = Left . PCEUnexpectedResponse $ bshow respOrErr - sendMsg :: Either err msg -> IO () - sendMsg = \case - Right msg -> atomically $ mapM_ (`writeTBQueue` serverTransmission c isResponse entId msg) msgQ - Left e -> putStrLn $ "SMP client error: " <> show e + clientResp = case respOrErr of + Left e -> Left $ PCEResponseError e + Right r -> case protocolError r of + Just e -> Left $ PCEProtocolError e + _ -> Right r + sendMsg :: TransmissionType msg -> IO () + sendMsg tType = case msgQ of + Just q -> atomically $ writeTBQueue q $ serverTransmission c tType entId clientResp + Nothing -> case clientResp of + Left e -> logError $ "SMP client error: " <> tshow e + Right _ -> logWarn $ "SMP client unprocessed event" proxyUsername :: TransportSession msg -> ByteString proxyUsername (userId, _, entityId_) = C.sha256Hash $ bshow userId <> maybe "" (":" <>) entityId_ @@ -558,11 +569,11 @@ processSUBResponse c (Response rId r) = case r of Left e -> pure $ Left e writeSMPMessage :: SMPClient -> RecipientId -> BrokerMsg -> IO () -writeSMPMessage c rId msg = atomically $ mapM_ (`writeTBQueue` serverTransmission c False rId msg) (msgQ $ client_ c) +writeSMPMessage c rId msg = atomically $ mapM_ (`writeTBQueue` serverTransmission c TTEvent rId (Right msg)) (msgQ $ client_ c) -serverTransmission :: ProtocolClient v err msg -> Bool -> RecipientId -> msg -> ServerTransmission v msg -serverTransmission ProtocolClient {thParams = THandleParams {thVersion, sessionId}, client_ = PClient {transportSession}} isResponse entityId message = - (transportSession, thVersion, sessionId, isResponse, entityId, message) +serverTransmission :: ProtocolClient v err msg -> TransmissionType msg -> RecipientId -> Either (ProtocolClientError err) msg -> ServerTransmission v err msg +serverTransmission ProtocolClient {thParams = THandleParams {thVersion, sessionId}, client_ = PClient {transportSession}} tType entityId msgOrErr = + (transportSession, thVersion, sessionId, tType, entityId, msgOrErr) -- | Get message from SMP queue. The server returns ERR PROHIBITED if a client uses SUB and GET via the same transport connection for the same queue -- @@ -687,7 +698,7 @@ sendSMPCommand c pKey qId cmd = sendProtocolCommand c pKey qId (Cmd sParty cmd) type PCTransmission err msg = (Either TransportError SentRawTransmission, Request err msg) -- | Send multiple commands with batching and collect responses -sendProtocolCommands :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> NonEmpty (ClientCommand msg) -> IO (NonEmpty (Response err msg)) +sendProtocolCommands :: forall v err msg. Protocol v err msg => ProtocolClient v err msg -> NonEmpty (ClientCommand msg) -> IO (NonEmpty (Response err msg)) sendProtocolCommands c@ProtocolClient {thParams = THandleParams {batch, blockSize}} cs = do bs <- batchTransmissions' batch blockSize <$> mapM (mkTransmission c) cs validate . concat =<< mapM (sendBatch c) bs @@ -704,30 +715,28 @@ sendProtocolCommands c@ProtocolClient {thParams = THandleParams {batch, blockSiz where diff = L.length cs - length rs -streamProtocolCommands :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> NonEmpty (ClientCommand msg) -> ([Response err msg] -> IO ()) -> IO () +streamProtocolCommands :: forall v err msg. Protocol v err msg => ProtocolClient v err msg -> NonEmpty (ClientCommand msg) -> ([Response err msg] -> IO ()) -> IO () streamProtocolCommands c@ProtocolClient {thParams = THandleParams {batch, blockSize}} cs cb = do bs <- batchTransmissions' batch blockSize <$> mapM (mkTransmission c) cs mapM_ (cb <=< sendBatch c) bs sendBatch :: ProtocolClient v err msg -> TransportBatch (Request err msg) -> IO [Response err msg] -sendBatch c@ProtocolClient {client_ = PClient {rcvConcurrency, sndQ}} b = do +sendBatch c@ProtocolClient {client_ = PClient {sndQ}} b = do case b of TBError e Request {entityId} -> do putStrLn "send error: large message" pure [Response entityId $ Left $ PCETransportError e] TBTransmissions s n rs | n > 0 -> do - active <- newTVarIO True - atomically $ writeTBQueue sndQ (active, s) - mapConcurrently (getResponse c active) rs + atomically $ writeTBQueue sndQ s + mapConcurrently (getResponse c) rs | otherwise -> pure [] TBTransmission s r -> do - active <- newTVarIO True - atomically $ writeTBQueue sndQ (active, s) - (: []) <$> getResponse c active r + atomically $ writeTBQueue sndQ s + (: []) <$> getResponse c r -- | Send Protocol command -sendProtocolCommand :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg +sendProtocolCommand :: forall v err msg. Protocol v err msg => ProtocolClient v err msg -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg sendProtocolCommand c@ProtocolClient {client_ = PClient {sndQ}, thParams = THandleParams {batch, blockSize}} pKey entId cmd = ExceptT $ uncurry sendRecv =<< mkTransmission c (pKey, entId, cmd) where @@ -738,30 +747,30 @@ sendProtocolCommand c@ProtocolClient {client_ = PClient {sndQ}, thParams = THand Right t | B.length s > blockSize - 2 -> pure . Left $ PCETransportError TELargeMsg | otherwise -> do - active <- newTVarIO True - atomically (writeTBQueue sndQ (active, s)) - response <$> getResponse c active r + atomically $ writeTBQueue sndQ s + response <$> getResponse c r where s | batch = tEncodeBatch1 t | otherwise = tEncode t --- TODO switch to timeout or TimeManager that supports Int64 -getResponse :: ProtocolClient v err msg -> TVar Bool -> Request err msg -> IO (Response err msg) -getResponse ProtocolClient {client_ = PClient {tcpTimeout, timeoutErrorCount, sentCommands}} active Request {corrId, entityId, responseVar} = do - response <- - timeout tcpTimeout (atomically (takeTMVar responseVar)) >>= \case - Just r -> atomically (writeTVar timeoutErrorCount 0) $> r - Nothing -> do - atomically (writeTVar active False >> TM.delete corrId sentCommands) - atomically $ modifyTVar' timeoutErrorCount (+ 1) - pure $ Left PCEResponseTimeout +getResponse :: ProtocolClient v err msg -> Request err msg -> IO (Response err msg) +getResponse ProtocolClient {client_ = PClient {tcpTimeout, timeoutErrorCount}} Request {entityId, pending, responseVar} = do + r <- tcpTimeout `timeout` atomically (takeTMVar responseVar) + response <- atomically $ do + writeTVar pending False + -- Try to read response again in case it arrived after timeout expired + -- but before `pending` was set to False above. + -- See `processMsg`. + ((r <|>) <$> tryTakeTMVar responseVar) >>= \case + Just r' -> writeTVar timeoutErrorCount 0 $> r' + Nothing -> modifyTVar' timeoutErrorCount (+ 1) $> Left PCEResponseTimeout pure Response {entityId, response} -mkTransmission :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> ClientCommand msg -> IO (PCTransmission err msg) -mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCommands}} (pKey_, entId, cmd) = do +mkTransmission :: forall v err msg. Protocol v err msg => ProtocolClient v err msg -> ClientCommand msg -> IO (PCTransmission err msg) +mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCommands}} (pKey_, entityId, command) = do corrId <- atomically getNextCorrId - let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, entId, cmd) + let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth thParams (corrId, entityId, command) auth = authTransmission (thAuth thParams) pKey_ corrId tForAuth r <- atomically $ mkRequest corrId pure ((,tToSend) <$> auth, r) @@ -770,7 +779,16 @@ mkTransmission ProtocolClient {thParams, client_ = PClient {clientCorrId, sentCo getNextCorrId = CorrId <$> C.randomBytes 24 clientCorrId -- also used as nonce mkRequest :: CorrId -> STM (Request err msg) mkRequest corrId = do - r <- Request corrId entId <$> newEmptyTMVar + pending <- newTVar True + responseVar <- newEmptyTMVar + let r = + Request + { corrId, + entityId, + command, + pending, + responseVar + } TM.insert corrId r sentCommands pure r diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 4b925c6f6..aed56f1bd 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -39,7 +39,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (BrokerMsg, NotifierId, NtfPrivateAuthKey, ProtocolServer (..), QueueId, RcvPrivateAuthKey, RecipientId, SMPServer) +import Simplex.Messaging.Protocol (BrokerMsg, ErrorType, NotifierId, NtfPrivateAuthKey, ProtocolServer (..), QueueId, RcvPrivateAuthKey, RecipientId, SMPServer) import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM @@ -94,7 +94,7 @@ defaultSMPClientAgentConfig = data SMPClientAgent = SMPClientAgent { agentCfg :: SMPClientAgentConfig, - msgQ :: TBQueue (ServerTransmission SMPVersion BrokerMsg), + msgQ :: TBQueue (ServerTransmission SMPVersion ErrorType BrokerMsg), agentQ :: TBQueue SMPClientAgentEvent, randomDrg :: TVar ChaChaDRG, smpClients :: TMap SMPServer SMPClientVar, diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index b79665c87..37eff1e94 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -218,10 +218,10 @@ ntfSubscriber NtfSubscriber {smpSubscribers, newSubQ, smpAgent = ca@SMPClientAge receiveSMP :: M () receiveSMP = forever $ do - ((_, srv, _), _, _, _, ntfId, msg) <- atomically $ readTBQueue msgQ + ((_, srv, _), _, _, _tType, ntfId, msgOrErr) <- atomically $ readTBQueue msgQ let smpQueue = SMPQueueNtf srv ntfId - case msg of - SMP.NMSG nmsgNonce encNMsgMeta -> do + case msgOrErr of + Right (SMP.NMSG nmsgNonce encNMsgMeta) -> do ntfTs <- liftIO getSystemTime st <- asks store NtfPushServer {pushQ} <- asks pushServer @@ -231,8 +231,10 @@ ntfSubscriber NtfSubscriber {smpSubscribers, newSubQ, smpAgent = ca@SMPClientAge findNtfSubscriptionToken st smpQueue >>= mapM_ (\tkn -> writeTBQueue pushQ (tkn, PNMessage PNMessageData {smpQueue, ntfTs, nmsgNonce, encNMsgMeta})) incNtfStat ntfReceived - SMP.END -> updateSubStatus smpQueue NSEnd - _ -> pure () + Right SMP.END -> updateSubStatus smpQueue NSEnd + Right (SMP.ERR e) -> logError $ "SMP server error: " <> tshow e + Right _ -> logError $ "SMP server unexpected response" + Left e -> logError $ "SMP client error: " <> tshow e receiveAgent = forever $ diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 85beccd6b..a09759814 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -894,9 +894,10 @@ client clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Serv Just msg -> let encMsg = encryptMsg qr msg in atomically (setDelivered s msg) $> (corrId, rId, MSG encMsg) - _ -> forkSub $> ok - _ -> pure ok + _ -> forkSub $> resp + _ -> pure resp where + resp = (corrId, rId, OK) forkSub :: M () forkSub = do atomically . modifyTVar' sub $ \s -> s {subThread = SubPending} diff --git a/src/Simplex/Messaging/Session.hs b/src/Simplex/Messaging/Session.hs index 7a219e106..75543b481 100644 --- a/src/Simplex/Messaging/Session.hs +++ b/src/Simplex/Messaging/Session.hs @@ -10,6 +10,7 @@ import Data.Composition ((.:.)) import Data.Functor (($>)) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (($>>=)) data SessionVar a = SessionVar { sessionVar :: TMVar a, @@ -36,3 +37,6 @@ removeSessVar' v sessKey vs = TM.lookup sessKey vs >>= \case Just v' | sessionVarId v == sessionVarId v' -> TM.delete sessKey vs $> True _ -> pure False + +tryReadSessVar :: Ord k => k -> TMap k (SessionVar a) -> STM (Maybe a) +tryReadSessVar sessKey vs = TM.lookup sessKey vs $>>= (tryReadTMVar . sessionVar) diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 8c06c0d82..e1d383b5a 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -127,7 +127,7 @@ smpBlockSize = 16384 -- 4 - support command batching (7/17/2022) -- 5 - basic auth for SMP servers (11/12/2022) -- 6 - allow creating queues without subscribing (9/10/2023) --- 7 - support authenticated encryption to verify senders' commands, imply but do NOT send session ID in signed part (2/3/2024) +-- 7 - support authenticated encryption to verify senders' commands, imply but do NOT send session ID in signed part (4/30/2024) data SMPVersion diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index 32610b54e..df117c105 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -242,7 +242,7 @@ testDuplexConnection' (alice, aPQ) (bob, bPQ) = do alice #: ("4a", "bob", "ACK 7") #> ("4a", "bob", OK) alice #: ("5", "bob", "OFF") #> ("5", "bob", OK) bob #: ("17", "alice", "SEND F 9\nmessage 3") #> ("17", "alice", A.MID 8 pq) - bob <# ("", "alice", MERR 8 (SMP AUTH)) + bob <#= \case ("", "alice", MERR 8 (SMP _ AUTH)) -> True; _ -> False alice #: ("6", "bob", "DEL") #> ("6", "bob", OK) alice #:# "nothing else should be delivered to alice" @@ -280,7 +280,7 @@ testDuplexConnRandomIds' (alice, aPQ) (bob, bPQ) = do alice #: ("4a", bobConn, "ACK 7") #> ("4a", bobConn, OK) alice #: ("5", bobConn, "OFF") #> ("5", bobConn, OK) bob #: ("17", aliceConn, "SEND F 9\nmessage 3") #> ("17", aliceConn, A.MID 8 pq) - bob <# ("", aliceConn, MERR 8 (SMP AUTH)) + bob <#= \case ("", cId, MERR 8 (SMP _ AUTH)) -> cId == aliceConn; _ -> False alice #: ("6", bobConn, "DEL") #> ("6", bobConn, OK) alice #:# "nothing else should be delivered to alice" @@ -383,7 +383,7 @@ testSubscrNotification t (server, _) client = do killThread server client <#. ("", "", DOWN testSMPServer ["conn1"]) withSmpServer (ATransport t) $ - client <# ("", "conn1", ERR (SMP AUTH)) -- this new server does not have the queue + client <#= \case ("", "conn1", ERR (SMP _ AUTH)) -> True; _ -> False -- this new server does not have the queue testMsgDeliveryServerRestart :: forall c. Transport c => (c, InitialKeys) -> (c, PQSupport) -> IO () testMsgDeliveryServerRestart (alice, aPQ) (bob, bPQ) = do @@ -630,7 +630,7 @@ syntaxTests t = do <> " subscribe " <> "14\nbob's connInfo" ) - >#> ("311", "a", "ERR SMP AUTH") + >#> ("311", "a", "ERR SMP smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001 AUTH") describe "invalid" $ do it "no parameters" $ ("321", "", "JOIN") >#> ("321", "", "ERR CMD SYNTAX") where diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 59e433ea5..f1dcc058a 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -422,7 +422,7 @@ functionalAPITests t = do describe "server with password" $ do let auth = Just "abcd" srv = ProtoServerWithAuth testSMPServer2 - authErr = Just (ProtocolTestFailure TSCreateQueue $ SMP AUTH) + authErr = Just (ProtocolTestFailure TSCreateQueue $ SMP (B.unpack $ strEncode testSMPServer2) AUTH) it "should pass with correct password" $ testSMPServerConnectionTest t auth (srv auth) `shouldReturn` Nothing it "should fail without password" $ testSMPServerConnectionTest t auth (srv Nothing) `shouldReturn` authErr it "should fail with incorrect password" $ testSMPServerConnectionTest t auth (srv $ Just "wrong") `shouldReturn` authErr @@ -537,7 +537,7 @@ runAgentClientTest pqSupport alice@AgentClient {} bob baseId = ackMessage alice bobId (baseId + 4) Nothing suspendConnection alice bobId 5 <- msgId <$> A.sendMessage bob aliceId pqEnc SMP.noMsgFlags "message 2" - get bob ##> ("", aliceId, MERR (baseId + 5) (SMP AUTH)) + get bob =##> \case ("", cId, MERR mId (SMP _ AUTH)) -> cId == aliceId && mId == (baseId + 5); _ -> False deleteConnection alice bobId liftIO $ noMessages alice "nothing else should be delivered to alice" where @@ -669,7 +669,7 @@ runAgentClientContactTest pqSupport alice bob baseId = ackMessage alice bobId (baseId + 4) Nothing suspendConnection alice bobId 5 <- msgId <$> A.sendMessage bob aliceId pqEnc SMP.noMsgFlags "message 2" - get bob ##> ("", aliceId, MERR (baseId + 5) (SMP AUTH)) + get bob =##> \case ("", cId, MERR mId (SMP _ AUTH)) -> cId == aliceId && mId == (baseId + 5); _ -> False deleteConnection alice bobId liftIO $ noMessages alice "nothing else should be delivered to alice" where @@ -1115,7 +1115,7 @@ testExpireMessageQuota t = withSmpServerConfigOn t cfg {msgQueueQuota = 1} testP 5 <- sendMessage a bId SMP.noMsgFlags "2" liftIO $ threadDelay 1000000 6 <- sendMessage a bId SMP.noMsgFlags "3" -- this won't expire - get a =##> \case ("", c, MERR 5 (SMP QUOTA)) -> bId == c; _ -> False + get a =##> \case ("", c, MERR 5 (SMP _ QUOTA)) -> bId == c; _ -> False pure (aId, bId) withAgent 3 agentCfg initAgentServers testDB2 $ \b' -> runRight_ $ do subscribeConnection b' aId @@ -1143,15 +1143,15 @@ testExpireManyMessagesQuota t = withSmpServerConfigOn t cfg {msgQueueQuota = 1} 7 <- sendMessage a bId SMP.noMsgFlags "4" liftIO $ threadDelay 1000000 8 <- sendMessage a bId SMP.noMsgFlags "5" -- this won't expire - get a =##> \case ("", c, MERR 5 (SMP QUOTA)) -> bId == c; _ -> False + get a =##> \case ("", c, MERR 5 (SMP _ QUOTA)) -> bId == c; _ -> False get a >>= \case - ("", c, MERR 6 (SMP QUOTA)) -> do + ("", c, MERR 6 (SMP _ QUOTA)) -> do liftIO $ bId `shouldBe` c - get a =##> \case ("", c', MERR 7 (SMP QUOTA)) -> bId == c'; ("", c', MERRS [7] (SMP QUOTA)) -> bId == c'; _ -> False - ("", c, MERRS [6] (SMP QUOTA)) -> do + get a =##> \case ("", c', MERR 7 (SMP _ QUOTA)) -> bId == c'; ("", c', MERRS [7] (SMP _ QUOTA)) -> bId == c'; _ -> False + ("", c, MERRS [6] (SMP _ QUOTA)) -> do liftIO $ bId `shouldBe` c - get a =##> \case ("", c', MERR 7 (SMP QUOTA)) -> bId == c'; _ -> False - ("", c, MERRS [6, 7] (SMP QUOTA)) -> liftIO $ bId `shouldBe` c + get a =##> \case ("", c', MERR 7 (SMP _ QUOTA)) -> bId == c'; _ -> False + ("", c, MERRS [6, 7] (SMP _ QUOTA)) -> liftIO $ bId `shouldBe` c r -> error $ show r pure (aId, bId) withAgent 3 agentCfg initAgentServers testDB2 $ \b' -> runRight_ $ do @@ -1402,10 +1402,10 @@ makeConnection = makeConnection_ PQSupportOn makeConnection_ :: PQSupport -> AgentClient -> AgentClient -> ExceptT AgentErrorType IO (ConnId, ConnId) makeConnection_ pqEnc alice bob = makeConnectionForUsers_ pqEnc alice 1 bob 1 -makeConnectionForUsers :: AgentClient -> UserId -> AgentClient -> UserId -> ExceptT AgentErrorType IO (ConnId, ConnId) +makeConnectionForUsers :: HasCallStack => AgentClient -> UserId -> AgentClient -> UserId -> ExceptT AgentErrorType IO (ConnId, ConnId) makeConnectionForUsers = makeConnectionForUsers_ PQSupportOn -makeConnectionForUsers_ :: PQSupport -> AgentClient -> UserId -> AgentClient -> UserId -> ExceptT AgentErrorType IO (ConnId, ConnId) +makeConnectionForUsers_ :: HasCallStack => PQSupport -> AgentClient -> UserId -> AgentClient -> UserId -> ExceptT AgentErrorType IO (ConnId, ConnId) makeConnectionForUsers_ pqSupport alice aliceUserId bob bobUserId = do (bobId, qInfo) <- A.createConnection alice aliceUserId True SCMInvitation Nothing (CR.IKNoPQ pqSupport) SMSubscribe aliceId <- A.prepareConnectionToJoin bob bobUserId True qInfo pqSupport @@ -1709,7 +1709,7 @@ testAcceptContactAsync = ackMessage alice bobId (baseId + 4) Nothing suspendConnection alice bobId 5 <- msgId <$> sendMessage bob aliceId SMP.noMsgFlags "message 2" - get bob ##> ("", aliceId, MERR (baseId + 5) (SMP AUTH)) + get bob =##> \case ("", cId, MERR mId (SMP _ AUTH)) -> cId == aliceId && mId == (baseId + 5); _ -> False deleteConnection alice bobId liftIO $ noMessages alice "nothing else should be delivered to alice" where @@ -1755,7 +1755,7 @@ testWaitDeliveryNoPending t = withAgentClients2 $ \alice bob -> get alice =##> \case ("", cId, DEL_CONN) -> cId == bobId; _ -> False 3 <- msgId <$> sendMessage bob aliceId SMP.noMsgFlags "message 2" - get bob ##> ("", aliceId, MERR (baseId + 3) (SMP AUTH)) + get bob =##> \case ("", cId, MERR mId (SMP _ AUTH)) -> cId == aliceId && mId == (baseId + 3); _ -> False liftIO $ noMessages alice "nothing else should be delivered to alice" liftIO $ noMessages bob "nothing else should be delivered to bob" @@ -1850,8 +1850,8 @@ testWaitDeliveryAUTHErr t = liftIO $ noMessages bob "nothing else should be delivered to bob" withSmpServerStoreLogOn t testPort $ \_ -> do - get alice ##> ("", bobId, MERR (baseId + 3) (SMP AUTH)) - get alice ##> ("", bobId, MERR (baseId + 4) (SMP AUTH)) + get alice =##> \case ("", cId, MERR mId (SMP _ AUTH)) -> cId == bobId && mId == (baseId + 3); _ -> False + get alice =##> \case ("", cId, MERR mId (SMP _ AUTH)) -> cId == bobId && mId == (baseId + 4); _ -> False get alice =##> \case ("", cId, DEL_CONN) -> cId == bobId; _ -> False liftIO $ noMessages alice "nothing else should be delivered to alice" @@ -2422,11 +2422,11 @@ testCreateQueueAuth srvVersion clnt1 clnt2 = do b <- getClient 2 clnt2 testDB2 r <- runRight $ do tryError (createConnection a 1 True SCMInvitation Nothing SMSubscribe) >>= \case - Left (SMP AUTH) -> pure 0 + Left (SMP _ AUTH) -> pure 0 Left e -> throwError e Right (bId, qInfo) -> tryError (joinConnection b 1 True qInfo "bob's connInfo" SMSubscribe) >>= \case - Left (SMP AUTH) -> pure 1 + Left (SMP _ AUTH) -> pure 1 Left e -> throwError e Right aId -> do ("", _, CONF confId _ "bob's connInfo") <- get a diff --git a/tests/AgentTests/NotificationTests.hs b/tests/AgentTests/NotificationTests.hs index 2c1045791..3b497c8d4 100644 --- a/tests/AgentTests/NotificationTests.hs +++ b/tests/AgentTests/NotificationTests.hs @@ -6,8 +6,8 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeApplications #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} +{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module AgentTests.NotificationTests where @@ -17,10 +17,6 @@ import AgentTests.FunctionalAPITests createConnection, exchangeGreetingsMsgId, get, - withAgent, - withAgentClients2, - withAgentClientsCfgServers2, - withAgentClients3, joinConnection, makeConnection, nGet, @@ -29,7 +25,11 @@ import AgentTests.FunctionalAPITests sendMessage, switchComplete, testServerMatrix2, + withAgent, + withAgentClients2, + withAgentClients3, withAgentClientsCfg2, + withAgentClientsCfgServers2, (##>), (=##>), pattern CON, @@ -59,8 +59,8 @@ import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO) import Simplex.Messaging.Agent.Store.SQLite (getSavedNtfToken) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..)) import Simplex.Messaging.Notifications.Protocol +import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..)) import Simplex.Messaging.Notifications.Server.Push.APNS import Simplex.Messaging.Notifications.Types (NtfToken (..)) import Simplex.Messaging.Protocol (ErrorType (AUTH), MsgFlags (MsgFlags), NtfServer, ProtocolServer (..), SMPMsgMeta (..), SubscriptionMode (..)) @@ -151,7 +151,8 @@ testNtfMatrix t runTest = do it "next servers: SMP v7, NTF v2; curr clients: v6/v1" $ runNtfTestCfg t cfgV7 ntfServerCfgV2 agentCfg agentCfg runTest it "curr servers: SMP v6, NTF v1; curr clients: v6/v1" $ runNtfTestCfg t cfg ntfServerCfg agentCfg agentCfg runTest skip "this case cannot be supported - see RFC" $ - it "servers: SMP v6, NTF v1; clients: v7/v2 (not supported)" $ runNtfTestCfg t cfg ntfServerCfg agentCfgV7 agentCfgV7 runTest + it "servers: SMP v6, NTF v1; clients: v7/v2 (not supported)" $ + runNtfTestCfg t cfg ntfServerCfg agentCfgV7 agentCfgV7 runTest -- servers can be migrated in any order it "servers: next SMP v7, curr NTF v1; curr clients: v6/v1" $ runNtfTestCfg t cfgV7 ntfServerCfg agentCfg agentCfg runTest it "servers: curr SMP v6, next NTF v2; curr clients: v6/v1" $ runNtfTestCfg t cfg ntfServerCfgV2 agentCfg agentCfg runTest @@ -243,7 +244,7 @@ testNtfTokenSecondRegistration APNSMockServer {apnsQ} = -- now the second token registration is verified verifyNtfToken a' tkn nonce' verification' -- the first registration is removed - Left (NTF AUTH) <- tryE $ checkNtfToken a tkn + Left (NTF _ AUTH) <- tryE $ checkNtfToken a tkn -- and the second is active NTActive <- checkNtfToken a' tkn pure () @@ -258,7 +259,7 @@ testNtfTokenServerRestart t APNSMockServer {apnsQ} = do atomically $ readTBQueue apnsQ liftIO $ sendApnsResponse APNSRespOk pure ntfData - -- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server + -- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server threadDelay 1000000 withAgent 2 agentCfg initAgentServers testDB $ \a' -> -- server stopped before token is verified, so now the attempt to verify it will return AUTH error but re-register token, @@ -266,7 +267,7 @@ testNtfTokenServerRestart t APNSMockServer {apnsQ} = do withNtfServer t . runRight_ $ do verification <- ntfData .-> "verification" nonce <- C.cbNonce <$> ntfData .-> "nonce" - Left (NTF AUTH) <- tryE $ verifyNtfToken a' tkn nonce verification + Left (NTF _ AUTH) <- tryE $ verifyNtfToken a' tkn nonce verification APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}, sendApnsResponse = sendApnsResponse'} <- atomically $ readTBQueue apnsQ verification' <- ntfData' .-> "verification" diff --git a/tests/CoreTests/ProtocolErrorTests.hs b/tests/CoreTests/ProtocolErrorTests.hs index 7b1a7b813..d574bfb4f 100644 --- a/tests/CoreTests/ProtocolErrorTests.hs +++ b/tests/CoreTests/ProtocolErrorTests.hs @@ -35,6 +35,9 @@ protocolErrorTests = modifyMaxSuccess (const 1000) $ do errHasSpaces = \case BROKER srv (RESPONSE e) -> hasSpaces srv || hasSpaces e BROKER srv _ -> hasSpaces srv + SMP srv _ -> hasSpaces srv + NTF srv _ -> hasSpaces srv + XFTP srv _ -> hasSpaces srv _ -> False hasSpaces s = ' ' `B.elem` encodeUtf8 (T.pack s) diff --git a/tests/XFTPAgent.hs b/tests/XFTPAgent.hs index 88786bb40..0610bf48d 100644 --- a/tests/XFTPAgent.hs +++ b/tests/XFTPAgent.hs @@ -69,7 +69,7 @@ xftpAgentTests = around_ testBracket . describe "agent XFTP API" $ do describe "server with password" $ do let auth = Just "abcd" srv = ProtoServerWithAuth testXFTPServer2 - authErr = Just (ProtocolTestFailure TSCreateFile $ XFTP AUTH) + authErr = Just (ProtocolTestFailure TSCreateFile $ XFTP (B.unpack $ strEncode testXFTPServer2) AUTH) it "should pass with correct password" $ testXFTPServerTest auth (srv auth) `shouldReturn` Nothing it "should fail without password" $ testXFTPServerTest auth (srv Nothing) `shouldReturn` authErr it "should fail with incorrect password" $ testXFTPServerTest auth (srv $ Just "wrong") `shouldReturn` authErr @@ -392,7 +392,8 @@ testXFTPAgentReceiveCleanup = withGlobalLogging logCfgNoLogs $ do -- receive file - should fail with AUTH error withAgent 3 agentCfg initAgentServers testDB2 $ \rcp' -> do runRight_ $ xftpStartWorkers rcp' (Just recipientFiles) - ("", rfId', RFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- rfGet rcp' + ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfGet rcp' rfId' `shouldBe` rfId -- tmp path should be removed after permanent error @@ -471,7 +472,8 @@ testXFTPAgentSendCleanup = withGlobalLogging logCfgNoLogs $ do -- send file - should fail with AUTH error withAgent 2 agentCfg initAgentServers testDB $ \sndr' -> do runRight_ $ xftpStartWorkers sndr' (Just senderFiles) - ("", sfId', SFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- sfGet sndr' + ("", sfId', SFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + sfGet sndr' sfId' `shouldBe` sfId -- prefix path should be removed after permanent error @@ -506,7 +508,8 @@ testXFTPAgentDelete = withGlobalLogging logCfgNoLogs $ withAgent 3 agentCfg initAgentServers testDB2 $ \rcp2 -> runRight $ do xftpStartWorkers rcp2 (Just recipientFiles) rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing - ("", rfId', RFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- rfGet rcp2 + ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfGet rcp2 liftIO $ rfId' `shouldBe` rfId testXFTPAgentDeleteRestore :: HasCallStack => IO () @@ -543,7 +546,8 @@ testXFTPAgentDeleteRestore = withGlobalLogging logCfgNoLogs $ do withAgent 5 agentCfg initAgentServers testDB3 $ \rcp2 -> runRight $ do xftpStartWorkers rcp2 (Just recipientFiles) rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing - ("", rfId', RFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- rfGet rcp2 + ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfGet rcp2 liftIO $ rfId' `shouldBe` rfId testXFTPAgentDeleteOnServer :: HasCallStack => IO () @@ -577,7 +581,8 @@ testXFTPAgentDeleteOnServer = withGlobalLogging logCfgNoLogs $ runRight_ . void $ do -- receive file 1 again rfId1 <- xftpReceiveFile rcp 1 rfd1_2 Nothing - ("", rfId1', RFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- rfGet rcp + ("", rfId1', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfGet rcp liftIO $ rfId1 `shouldBe` rfId1' -- receive file 2 @@ -609,7 +614,8 @@ testXFTPAgentExpiredOnServer = withGlobalLogging logCfgNoLogs $ do -- receive file 1 again - should fail with AUTH error runRight $ do rfId <- xftpReceiveFile rcp 1 rfd1_2 Nothing - ("", rfId', RFERR (INTERNAL "XFTP {xftpErr = AUTH}")) <- rfGet rcp + ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfGet rcp liftIO $ rfId' `shouldBe` rfId -- create and send file 2 From 66c916dbb33e625cc40cc43aa2aededab859156b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 12 May 2024 21:12:01 +0100 Subject: [PATCH 053/107] proxy: increase client timeout for proxied commands (#1145) --- src/Simplex/Messaging/Client.hs | 35 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 92dd4047b..62d75c3e2 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -143,6 +143,7 @@ data PClient v err msg = PClient { connected :: TVar Bool, transportSession :: TransportSession msg, transportHost :: TransportHost, + tcpConnectTimeout :: Int, tcpTimeout :: Int, rcvConcurrency :: Int, sendPings :: TVar Bool, @@ -185,6 +186,7 @@ smpClientStub g sessionId thVersion thAuth = do { connected, transportSession = (1, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001", Nothing), transportHost = "localhost", + tcpConnectTimeout = 20_000_000, tcpTimeout = 15_000_000, rcvConcurrency = 8, sendPings, @@ -413,6 +415,7 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize { connected, transportSession, transportHost, + tcpConnectTimeout, tcpTimeout, sendPings, lastReceived, @@ -735,9 +738,9 @@ deleteSMPQueues = okSMPCommands DEL -- send PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- receives PKEY :: SessionId -> X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg connectSMPProxiedRelay :: SMPClient -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO ProxiedRelay -connectSMPProxiedRelay c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth +connectSMPProxiedRelay c@ProtocolClient {client_ = PClient {tcpConnectTimeout, tcpTimeout}} relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxyAuth | thVersion (thParams c) >= sendingProxySMPVersion = - sendSMPCommand c Nothing "" (PRXY relayServ proxyAuth) >>= \case + sendProtocolCommand_ c Nothing tOut Nothing "" (Cmd SProxiedClient (PRXY relayServ proxyAuth)) >>= \case PKEY sId vr (chain, key) -> case supportedClientSMPRelayVRange `compatibleVersion` vr of Nothing -> throwE $ transportErr TEVersion @@ -745,6 +748,7 @@ connectSMPProxiedRelay c relayServ@ProtocolServer {keyHash = C.KeyHash kh} proxy r -> throwE . PCEUnexpectedResponse $ bshow r | otherwise = throwE $ PCETransportError TEVersion where + tOut = Just $ tcpConnectTimeout + tcpTimeout transportErr = PCEProtocolError . PROXY . BROKER . TRANSPORT validateRelay :: X.CertificateChain -> X.SignedExact X.PubKey -> Either String C.PublicKeyX25519 validateRelay (X.CertificateChain cert) exact = do @@ -819,7 +823,7 @@ proxySMPMessage :: MsgFlags -> MsgBody -> ExceptT SMPClientError IO (Either ProxyClientError ()) -proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g}} (ProxiedRelay sessionId v serverKey) spKey sId flags msg = do +proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {clientCorrId = g, tcpTimeout}} (ProxiedRelay sessionId v serverKey) spKey sId flags msg = do -- prepare params let serverThAuth = (\ta -> ta {serverPeerPubKey = serverKey}) <$> thAuth proxyThParams serverThParams = smpTHParamsSetVersion v proxyThParams {sessionId, thAuth = serverThAuth} @@ -827,7 +831,7 @@ proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c let cmdSecret = C.dh' serverKey cmdPrivKey nonce@(C.CbNonce corrId) <- liftIO . atomically $ C.randomCbNonce g -- encode - let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth serverThParams (CorrId corrId, sId, Cmd SSender $ SEND flags msg) + let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth serverThParams (CorrId corrId, sId, Cmd SSender (SEND flags msg)) auth <- liftEitherWith PCETransportError $ authTransmission serverThAuth spKey nonce tForAuth b <- case batchTransmissions (batch serverThParams) (blockSize serverThParams) [Right (auth, tToSend)] of [] -> throwE $ PCETransportError TELargeMsg @@ -836,7 +840,8 @@ proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c TBTransmissions s _ _ : _ -> pure s et <- liftEitherWith PCECryptoError $ EncTransmission <$> C.cbEncrypt cmdSecret nonce b paddedProxiedMsgLength -- proxy interaction errors are wrapped - tryE (sendProtocolCommand_ c (Just nonce) Nothing sessionId (Cmd SProxiedClient (PFWD v cmdPubKey et))) >>= \case + let tOut = Just $ 2 * tcpTimeout + tryE (sendProtocolCommand_ c (Just nonce) tOut Nothing sessionId (Cmd SProxiedClient (PFWD v cmdPubKey et))) >>= \case Right r -> case r of PRES (EncResponse er) -> do -- server interaction errors are thrown directly @@ -872,7 +877,7 @@ forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = let fwdT = FwdTransmission {fwdCorrId, fwdVersion, fwdKey, fwdTransmission} eft = EncFwdTransmission $ C.cbEncryptNoPad sessSecret nonce (smpEncode fwdT) -- send - sendProtocolCommand_ c (Just nonce) Nothing "" (Cmd SSender (RFWD eft)) >>= \case + sendProtocolCommand_ c (Just nonce) Nothing Nothing "" (Cmd SSender (RFWD eft)) >>= \case RRES (EncFwdResponse efr) -> do -- unwrap r' <- liftEitherWith PCECryptoError $ C.cbDecryptNoPad sessSecret (C.reverseNonce nonce) efr @@ -936,19 +941,19 @@ sendBatch c@ProtocolClient {client_ = PClient {sndQ}} b = do | n > 0 -> do active <- newTVarIO True atomically $ writeTBQueue sndQ (active, s) - mapConcurrently (getResponse c active) rs + mapConcurrently (getResponse c Nothing active) rs | otherwise -> pure [] TBTransmission s r -> do active <- newTVarIO True atomically $ writeTBQueue sndQ (active, s) - (: []) <$> getResponse c active r + (: []) <$> getResponse c Nothing active r -- | Send Protocol command sendProtocolCommand :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg -sendProtocolCommand c = sendProtocolCommand_ c Nothing +sendProtocolCommand c = sendProtocolCommand_ c Nothing Nothing -sendProtocolCommand_ :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.CbNonce -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg -sendProtocolCommand_ c@ProtocolClient {client_ = PClient {sndQ}, thParams = THandleParams {batch, blockSize}} nonce_ pKey entId cmd = +sendProtocolCommand_ :: forall v err msg. ProtocolEncoding v err (ProtoCommand msg) => ProtocolClient v err msg -> Maybe C.CbNonce -> Maybe Int -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg +sendProtocolCommand_ c@ProtocolClient {client_ = PClient {sndQ}, thParams = THandleParams {batch, blockSize}} nonce_ tOut pKey entId cmd = ExceptT $ uncurry sendRecv =<< mkTransmission_ c nonce_ (pKey, entId, cmd) where -- two separate "atomically" needed to avoid blocking @@ -960,17 +965,17 @@ sendProtocolCommand_ c@ProtocolClient {client_ = PClient {sndQ}, thParams = THan | otherwise -> do active <- newTVarIO True atomically (writeTBQueue sndQ (active, s)) - response <$> getResponse c active r + response <$> getResponse c tOut active r where s | batch = tEncodeBatch1 t | otherwise = tEncode t -- TODO switch to timeout or TimeManager that supports Int64 -getResponse :: ProtocolClient v err msg -> TVar Bool -> Request err msg -> IO (Response err msg) -getResponse ProtocolClient {client_ = PClient {tcpTimeout, timeoutErrorCount, sentCommands}} active Request {corrId, entityId, responseVar} = do +getResponse :: ProtocolClient v err msg -> Maybe Int -> TVar Bool -> Request err msg -> IO (Response err msg) +getResponse ProtocolClient {client_ = PClient {tcpTimeout, timeoutErrorCount, sentCommands}} tOut active Request {corrId, entityId, responseVar} = do response <- - timeout tcpTimeout (atomically (takeTMVar responseVar)) >>= \case + fromMaybe tcpTimeout tOut `timeout` atomically (takeTMVar responseVar) >>= \case Just r -> atomically (writeTVar timeoutErrorCount 0) $> r Nothing -> do atomically (writeTVar active False >> TM.delete corrId sentCommands) From 4455b8bd0e243aa3bb4dc854037b2e64677963b0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 13 May 2024 08:10:40 +0100 Subject: [PATCH 054/107] agent: do not throw exception when command is created for deleted connection (#1150) * agent: do not throw exception when command is created for deleted connection * convert database busy/locked to critical alert --- src/Simplex/Messaging/Agent.hs | 2 +- src/Simplex/Messaging/Agent/Client.hs | 14 +++++++++++--- src/Simplex/Messaging/Agent/Store.hs | 4 +++- src/Simplex/Messaging/Agent/Store/SQLite.hs | 13 +++++++++---- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index f834927d8..198fabdba 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -1994,7 +1994,7 @@ subscriber :: AgentClient -> AM' () subscriber c@AgentClient {subQ, msgQ} = forever $ do t <- atomically $ readTBQueue msgQ agentOperationBracket c AORcvNetwork waitUntilActive $ - runExceptT (processSMPTransmission c t) >>= \case + tryAgentError' (processSMPTransmission c t) >>= \case Left e -> do logError $ tshow e atomically $ writeTBQueue subQ ("", "", APC SAEConn $ ERR e) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index d9b059d59..4c359a519 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -172,6 +172,7 @@ import Data.Text.Encoding import Data.Time (UTCTime, defaultTimeLocale, diffUTCTime, formatTime, getCurrentTime) import Data.Time.Clock.System (getSystemTime) import Data.Word (Word16) +import qualified Database.SQLite.Simple as SQL import Network.Socket (HostName) import Simplex.FileTransfer.Client (XFTPChunkSpec (..), XFTPClient, XFTPClientConfig (..), XFTPClientError) import qualified Simplex.FileTransfer.Client as X @@ -1621,10 +1622,16 @@ withStore :: AgentClient -> (DB.Connection -> IO (Either StoreError a)) -> AM a withStore c action = do st <- asks store withExceptT storeError . ExceptT . liftIO . agentOperationBracket c AODatabase (\_ -> pure ()) $ - withTransaction st action `E.catch` handleInternal "" + withTransaction st action `E.catches` handleDBErrors where - handleInternal :: String -> E.SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternal . B.pack $ show e <> ctxStr + handleDBErrors :: [E.Handler IO (Either StoreError a)] + handleDBErrors = + [ E.Handler $ \(e :: SQL.SQLError) -> + let se = SQL.sqlError e + busy = se == SQL.ErrorBusy || se == SQL.ErrorLocked + in pure . Left . (if busy then SEDatabaseBusy else SEInternal) $ bshow se, + E.Handler $ \(E.SomeException e) -> pure . Left $ SEInternal $ bshow e + ] withStoreBatch :: Traversable t => AgentClient -> (DB.Connection -> t (IO (Either AgentErrorType a))) -> AM' (t (Either AgentErrorType a)) withStoreBatch c actions = do @@ -1652,6 +1659,7 @@ storeError = \case -- it is used to wrap agent operations when "transaction-like" store access is needed -- NOTE: network IO should NOT be used inside AgentStoreMonad SEAgentError e -> e + SEDatabaseBusy e -> CRITICAL True $ B.unpack e e -> INTERNAL $ show e incStat :: AgentClient -> Int -> AgentStatsKey -> STM () diff --git a/src/Simplex/Messaging/Agent/Store.hs b/src/Simplex/Messaging/Agent/Store.hs index ce76d5c89..b3decd8f0 100644 --- a/src/Simplex/Messaging/Agent/Store.hs +++ b/src/Simplex/Messaging/Agent/Store.hs @@ -30,7 +30,7 @@ import Data.Type.Equality import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.RetryInterval (RI2State) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (RatchetX448, PQEncryption, PQSupport) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport, RatchetX448) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol ( MsgBody, @@ -593,6 +593,8 @@ type AsyncCmdId = Int64 data StoreError = -- | IO exceptions in store actions. SEInternal ByteString + | -- | Database busy + SEDatabaseBusy ByteString | -- | Failed to generate unique random ID SEUniqueID | -- | User ID not found diff --git a/src/Simplex/Messaging/Agent/Store/SQLite.hs b/src/Simplex/Messaging/Agent/Store/SQLite.hs index b8b1c7c52..beac334fb 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Store/SQLite.hs @@ -221,6 +221,7 @@ module Simplex.Messaging.Agent.Store.SQLite ) where +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class @@ -268,7 +269,7 @@ import Simplex.Messaging.Agent.Store.SQLite.Migrations (DownMigration (..), MTRE import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) -import Simplex.Messaging.Crypto.Ratchet (RatchetX448, SkippedMsgDiff (..), SkippedMsgKeys, PQEncryption (..), PQSupport (..)) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), RatchetX448, SkippedMsgDiff (..), SkippedMsgKeys) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -278,7 +279,7 @@ import Simplex.Messaging.Parsers (blobFieldParser, defaultJSON, dropPrefix, from import Simplex.Messaging.Protocol import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (bshow, catchAllErrors, eitherToMaybe, ifM, safeDecodeUtf8, ($>>=), (<$$>)) +import Simplex.Messaging.Util (bshow, catchAllErrors, eitherToMaybe, ifM, safeDecodeUtf8, tshow, ($>>=), (<$$>)) import Simplex.Messaging.Version.Internal import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist) import System.Exit (exitFailure) @@ -1272,12 +1273,16 @@ createCommand :: DB.Connection -> ACorrId -> ConnId -> Maybe SMPServer -> AgentC createCommand db corrId connId srv_ cmd = runExceptT $ do (host_, port_, serverKeyHash_) <- serverFields createdAt <- liftIO getCurrentTime - liftIO $ + liftIO . E.handle handleErr $ DB.execute db "INSERT INTO commands (host, port, corr_id, conn_id, command_tag, command, server_key_hash, created_at) VALUES (?,?,?,?,?,?,?,?)" - (host_, port_, corrId, connId, agentCommandTag cmd, cmd, serverKeyHash_, createdAt) + (host_, port_, corrId, connId, cmdTag, cmd, serverKeyHash_, createdAt) where + cmdTag = agentCommandTag cmd + handleErr e + | SQL.sqlError e == SQL.ErrorConstraint = logError $ "tried to create command " <> tshow cmdTag <> " for deleted connection" + | otherwise = E.throwIO e serverFields :: ExceptT StoreError IO (Maybe (NonEmpty TransportHost), Maybe ServiceName, Maybe C.KeyHash) serverFields = case srv_ of Just srv@(SMPServer host port _) -> From 969951d963a6b326b01984de55831b612b0cbdbe Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 13 May 2024 09:46:37 +0000 Subject: [PATCH 055/107] actions: ignore uploading GHC 8.10.7 binaries (#1064) In other words, finally upload 22.04 binaries. --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4c44cff9..23fda6a0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,7 +63,7 @@ jobs: mv $(cabal list-bin xftp) xftp-ubuntu-${{ matrix.platform_name}} - name: Build changelog - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-22.04' id: build_changelog uses: mikepenz/release-changelog-builder-action@v1 with: @@ -75,7 +75,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create release - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04' && matrix.ghc == '9.6.3' + if: startsWith(github.ref, 'refs/tags/v') && matrix.ghc != '8.10.7' uses: softprops/action-gh-release@v1 with: body: | From 512afa1e2bc721241a78e8cc9feaf68b5775d919 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 13 May 2024 15:16:20 +0100 Subject: [PATCH 056/107] agent: count received duplicate messages (#1148) * agent: count received duplicate messages * count total too * names * fix * tuple --- src/Simplex/Messaging/Agent.hs | 20 ++++++++++++++++++++ src/Simplex/Messaging/Agent/Client.hs | 3 +++ 2 files changed, 23 insertions(+) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 198fabdba..f7280facc 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -113,6 +113,7 @@ module Simplex.Messaging.Agent debugAgentLocks, getAgentStats, resetAgentStats, + getMsgCounts, getAgentSubscriptions, logConnection, ) @@ -554,6 +555,9 @@ resetAgentStats :: AgentClient -> IO () resetAgentStats = atomically . TM.clear . agentStats {-# INLINE resetAgentStats #-} +getMsgCounts :: AgentClient -> IO [(ConnId, (Int, Int))] -- (total, duplicates) +getMsgCounts c = readTVarIO (msgCounts c) >>= mapM (\(connId, cnt) -> (connId,) <$> readTVarIO cnt) . M.assocs + withAgentEnv' :: AgentClient -> AM' a -> IO a withAgentEnv' c = (`runReaderT` agentEnv c) {-# INLINE withAgentEnv' #-} @@ -2135,6 +2139,7 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, _ -> pure () let encryptedMsgHash = C.sha256Hash encAgentMessage g <- asks random + atomically updateTotalMsgCount tryError (agentClientMsg g encryptedMsgHash) >>= \case Right (Just (msgId, msgMeta, aMessage, rcPrev)) -> do conn'' <- resetRatchetSync @@ -2168,6 +2173,7 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, | otherwise = pure conn' Right _ -> prohibited >> ack Left e@(AGENT A_DUPLICATE) -> do + atomically updateDupMsgCount withStore' c (\db -> getLastMsg db connId srvMsgId) >>= \case Just RcvMsg {internalId, msgMeta, msgBody = agentMsgBody, userAck} | userAck -> ackDel internalId @@ -2198,6 +2204,20 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, checkDuplicateHash e encryptedMsgHash = unlessM (withStore' c $ \db -> checkRcvMsgHashExists db connId encryptedMsgHash) $ throwError e + updateTotalMsgCount :: STM () + updateTotalMsgCount = + TM.lookup connId (msgCounts c) >>= \case + Just v -> modifyTVar' v $ first (+ 1) + Nothing -> addMsgCount 0 + updateDupMsgCount :: STM () + updateDupMsgCount = + TM.lookup connId (msgCounts c) >>= \case + Just v -> modifyTVar' v $ second (+ 1) + Nothing -> addMsgCount 1 + addMsgCount :: Int -> STM () + addMsgCount duplicate = do + counts <- newTVar (1, duplicate) + TM.insert connId counts (msgCounts c) agentClientMsg :: TVar ChaChaDRG -> ByteString -> AM (Maybe (InternalId, MsgMeta, AMessage, CR.RatchetX448)) agentClientMsg g encryptedMsgHash = withStore c $ \db -> runExceptT $ do rc <- ExceptT $ getRatchet db connId -- ratchet state pre-decryption - required for processing EREADY diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 4c359a519..0e4a5b49e 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -298,6 +298,7 @@ data AgentClient = AgentClient -- smpSubWorkers for SMP servers sessions smpSubWorkers :: TMap SMPTransportSession (SessionVar (Async ())), agentStats :: TMap AgentStatsKey (TVar Int), + msgCounts :: TMap ConnId (TVar (Int, Int)), -- (total, duplicates) clientId :: Int, agentEnv :: Env } @@ -459,6 +460,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = deleteLock <- createLock smpSubWorkers <- TM.empty agentStats <- TM.empty + msgCounts <- TM.empty return AgentClient { acThread, @@ -494,6 +496,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = deleteLock, smpSubWorkers, agentStats, + msgCounts, clientId, agentEnv } From 762909ce33c01d71bbabfed156e1e9faeebbedca Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 13 May 2024 20:35:46 +0100 Subject: [PATCH 057/107] 5.8.0.0 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 02a088e23..0aed0c806 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.4.0 +version: 5.8.0.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 3366cb0b8..92b6a9cfb 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.4.0 +version: 5.8.0.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From f51cf1deacd14aced88652ccfa06a746999e2ac6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 14 May 2024 20:04:51 +0100 Subject: [PATCH 058/107] agent: use MVar for DB connection for more fair connection distribution between threads (#1147) --- src/Simplex/Messaging/Agent/Store/SQLite.hs | 9 ++++---- .../Messaging/Agent/Store/SQLite/Common.hs | 21 ++++++++----------- tests/AgentTests/SQLiteTests.hs | 7 ++++--- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Store/SQLite.hs b/src/Simplex/Messaging/Agent/Store/SQLite.hs index beac334fb..e47d2a15c 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Store/SQLite.hs @@ -287,6 +287,7 @@ import System.FilePath (takeDirectory) import System.IO (hFlush, stdout) import UnliftIO.Exception (bracketOnError, onException) import qualified UnliftIO.Exception as E +import UnliftIO.MVar import UnliftIO.STM -- * SQLite Store implementation @@ -382,8 +383,8 @@ connectSQLiteStore :: FilePath -> ScrubbedBytes -> Bool -> IO SQLiteStore connectSQLiteStore dbFilePath key keepKey = do dbNew <- not <$> doesFileExist dbFilePath dbConn <- dbBusyLoop (connectDB dbFilePath key) + dbConnection <- newMVar dbConn atomically $ do - dbConnection <- newTMVar dbConn dbKey <- newTVar $! storeKey key keepKey dbClosed <- newTVar False pure SQLiteStore {dbFilePath, dbKey, dbConnection, dbNew, dbClosed} @@ -421,14 +422,14 @@ openSQLiteStore st@SQLiteStore {dbClosed} key keepKey = openSQLiteStore_ :: SQLiteStore -> ScrubbedBytes -> Bool -> IO () openSQLiteStore_ SQLiteStore {dbConnection, dbFilePath, dbKey, dbClosed} key keepKey = bracketOnError - (atomically $ takeTMVar dbConnection) - (atomically . tryPutTMVar dbConnection) + (takeMVar dbConnection) + (tryPutMVar dbConnection) $ \DB.Connection {slow} -> do DB.Connection {conn} <- connectDB dbFilePath key atomically $ do - putTMVar dbConnection DB.Connection {conn, slow} writeTVar dbClosed False writeTVar dbKey $! storeKey key keepKey + putMVar dbConnection DB.Connection {conn, slow} reopenSQLiteStore :: SQLiteStore -> IO () reopenSQLiteStore st@SQLiteStore {dbKey, dbClosed} = diff --git a/src/Simplex/Messaging/Agent/Store/SQLite/Common.hs b/src/Simplex/Messaging/Agent/Store/SQLite/Common.hs index 18c16cc8b..b9a9bd501 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite/Common.hs +++ b/src/Simplex/Messaging/Agent/Store/SQLite/Common.hs @@ -22,8 +22,8 @@ import Database.SQLite.Simple (SQLError) import qualified Database.SQLite.Simple as SQL import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (diffToMilliseconds) -import UnliftIO.Exception (bracket) import qualified UnliftIO.Exception as E +import UnliftIO.MVar import UnliftIO.STM storeKey :: ScrubbedBytes -> Bool -> Maybe ScrubbedBytes @@ -32,16 +32,13 @@ storeKey key keepKey = if keepKey || BA.null key then Just key else Nothing data SQLiteStore = SQLiteStore { dbFilePath :: FilePath, dbKey :: TVar (Maybe ScrubbedBytes), - dbConnection :: TMVar DB.Connection, + dbConnection :: MVar DB.Connection, dbClosed :: TVar Bool, dbNew :: Bool } withConnection :: SQLiteStore -> (DB.Connection -> IO a) -> IO a -withConnection SQLiteStore {dbConnection} = - bracket - (atomically $ takeTMVar dbConnection) - (atomically . putTMVar dbConnection) +withConnection SQLiteStore {dbConnection} = withMVar dbConnection withConnection' :: SQLiteStore -> (SQL.Connection -> IO a) -> IO a withConnection' st action = withConnection st $ action . DB.conn @@ -71,9 +68,9 @@ dbBusyLoop action = loop 500 3000000 loop :: Int -> Int -> IO a loop t tLim = action `E.catch` \(e :: SQLError) -> - let se = SQL.sqlError e in - if tLim > t && (se == SQL.ErrorBusy || se == SQL.ErrorLocked) - then do - threadDelay t - loop (t * 9 `div` 8) (tLim - t) - else E.throwIO e + let se = SQL.sqlError e + in if tLim > t && (se == SQL.ErrorBusy || se == SQL.ErrorLocked) + then do + threadDelay t + loop (t * 9 `div` 8) (tLim - t) + else E.throwIO e diff --git a/tests/AgentTests/SQLiteTests.hs b/tests/AgentTests/SQLiteTests.hs index 4bac4fb83..436dd0eca 100644 --- a/tests/AgentTests/SQLiteTests.hs +++ b/tests/AgentTests/SQLiteTests.hs @@ -5,8 +5,8 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} @@ -17,6 +17,7 @@ module AgentTests.SQLiteTests (storeTests) where import AgentTests.EqInstances () import Control.Concurrent.Async (concurrently_) +import Control.Concurrent.MVar import Control.Concurrent.STM import Control.Exception (SomeException) import Control.Monad (replicateM_) @@ -45,9 +46,9 @@ import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (InitialKeys (..), pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Protocol (SubscriptionMode (..), pattern VersionSMPC) import qualified Simplex.Messaging.Protocol as SMP @@ -88,7 +89,7 @@ removeStore db = do removeFile $ dbFilePath db where close :: SQLiteStore -> IO () - close st = mapM_ DB.close =<< atomically (tryTakeTMVar $ dbConnection st) + close st = mapM_ DB.close =<< tryTakeMVar (dbConnection st) storeTests :: Spec storeTests = do From 6d190333751c8157cf95736c8bb3aa69cb956f2b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 15 May 2024 12:06:42 +0100 Subject: [PATCH 059/107] proxy: remember server connection error for some time --- src/Simplex/Messaging/Client/Agent.hs | 37 ++++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index d32d59b3f..e54c6e5ff 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -2,6 +2,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} @@ -33,6 +34,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (listToMaybe) import Data.Set (Set) import Data.Text.Encoding +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime) import Data.Tuple (swap) import Numeric.Natural import Simplex.Messaging.Agent.RetryInterval @@ -44,14 +46,14 @@ import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport -import Simplex.Messaging.Util (catchAll_, toChunks, ($>>=)) +import Simplex.Messaging.Util (catchAll_, ifM, toChunks, ($>>=)) import System.Timeout (timeout) import UnliftIO (async) import UnliftIO.Exception (Exception) import qualified UnliftIO.Exception as E import UnliftIO.STM -type SMPClientVar = SessionVar (Either SMPClientError SMPClient) +type SMPClientVar = SessionVar (Either (SMPClientError, Maybe UTCTime) SMPClient) data SMPClientAgentEvent = CAConnected SMPServer @@ -70,6 +72,7 @@ type SMPSub = (SMPSubParty, QueueId) data SMPClientAgentConfig = SMPClientAgentConfig { smpCfg :: ProtocolClientConfig SMPVersion, reconnectInterval :: RetryInterval, + persistErrorInterval :: NominalDiffTime, msgQSize :: Natural, agentQSize :: Natural, agentSubsBatchSize :: Int @@ -85,6 +88,7 @@ defaultSMPClientAgentConfig = increaseAfter = 10 * second, maxInterval = 10 * second }, + persistErrorInterval = 0, msgQSize = 256, agentQSize = 256, agentSubsBatchSize = 900 @@ -168,10 +172,15 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, waitForSMPClient v = do let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout}} = smpCfg agentCfg smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) - liftEither $ case smpClient_ of - Just (Right smpClient) -> Right smpClient - Just (Left e) -> Left e - Nothing -> Left PCEResponseTimeout + case smpClient_ of + Just (Right smpClient) -> pure smpClient + Just (Left (e, Nothing)) -> throwE e + Just (Left (e, Just ts)) -> + ifM + ((ts <) <$> liftIO getCurrentTime) + (atomically (removeSessVar v srv smpClients) >> getSMPServerClient' ca srv) + (throwE e) + Nothing -> throwE PCEResponseTimeout newSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient newSMPClient v = tryConnectClient pure (liftIO tryConnectAsync) @@ -182,15 +191,19 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, Right smp -> do logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv atomically $ do - putTMVar (sessionVar v) r + putTMVar (sessionVar v) (Right smp) TM.insert (sessionId $ thParams smp) smp smpSessions successAction smp Left e -> do - if e == PCENetworkError || e == PCEResponseTimeout - then retryAction - else atomically $ do - putTMVar (sessionVar v) (Left e) - removeSessVar v srv smpClients + if + | e == PCENetworkError || e == PCEResponseTimeout -> retryAction + | persistErrorInterval agentCfg == 0 -> do + atomically $ do + putTMVar (sessionVar v) (Left (e, Nothing)) + removeSessVar v srv smpClients + | otherwise -> do + ts <- addUTCTime (persistErrorInterval agentCfg) <$> liftIO getCurrentTime + atomically $ putTMVar (sessionVar v) (Left (e, Just ts)) throwE e tryConnectAsync :: IO () tryConnectAsync = do From 16d79da73e8f56792b312d3d1db3d3384f8f987c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 15 May 2024 15:15:59 +0100 Subject: [PATCH 060/107] config --- src/Simplex/Messaging/Server/Main.hs | 3 ++- tests/SMPClient.hs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index 418ef36c8..fa1698653 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -244,7 +244,8 @@ smpServerCLI cfgPath logPath = hostMode = either (const HMPublic) textToHostMode $ lookupValue "PROXY" "host_mode" ini, requiredHostMode = fromMaybe False $ iniOnOff "PROXY" "required_host_mode" ini } - } + }, + persistErrorInterval = 30 -- seconds }, allowSMPProxy = True } diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 441ece34b..d2e11d29b 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -133,7 +133,11 @@ proxyCfg = cfgV7 { allowSMPProxy = True, smpServerVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, - smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = proxyVRange, agreeSecret = True}} + smpAgentCfg = + defaultSMPClientAgentConfig + { smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = proxyVRange, agreeSecret = True}, + persistErrorInterval = 3 -- seconds + } } proxyVRange :: VersionRangeSMP From 426f47c8059e731681e18c6d08d62f74e1646d81 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 16 May 2024 21:06:27 +0300 Subject: [PATCH 061/107] smp: use session vars for reconnecting small agent (#1152) * smp: use session vars for reconnecting small agent * process errors * split session and protocol functions * add active flag to agent * actually invoke agent shutdown * close proxy agent too * restore stopping ntf subscribers --- src/Simplex/Messaging/Client/Agent.hs | 201 +++++++++--------- src/Simplex/Messaging/Notifications/Server.hs | 10 +- src/Simplex/Messaging/Server.hs | 10 +- 3 files changed, 118 insertions(+), 103 deletions(-) diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index e54c6e5ff..e08f7b2c9 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -15,6 +15,7 @@ module Simplex.Messaging.Client.Agent where import Control.Concurrent (forkIO) import Control.Concurrent.Async (Async, uninterruptibleCancel) +import Control.Concurrent.STM (retry) import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -46,7 +47,7 @@ import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport -import Simplex.Messaging.Util (catchAll_, ifM, toChunks, ($>>=)) +import Simplex.Messaging.Util (catchAll_, ifM, toChunks, whenM, ($>>=)) import System.Timeout (timeout) import UnliftIO (async) import UnliftIO.Exception (Exception) @@ -58,7 +59,6 @@ type SMPClientVar = SessionVar (Either (SMPClientError, Maybe UTCTime) SMPClient data SMPClientAgentEvent = CAConnected SMPServer | CADisconnected SMPServer (Set SMPSub) - | CAReconnected SMPServer | CAResubscribed SMPServer (NonEmpty SMPSub) | CASubError SMPServer (NonEmpty (SMPSub, SMPClientError)) @@ -98,6 +98,7 @@ defaultSMPClientAgentConfig = data SMPClientAgent = SMPClientAgent { agentCfg :: SMPClientAgentConfig, + active :: TVar Bool, msgQ :: TBQueue (ServerTransmission SMPVersion ErrorType BrokerMsg), agentQ :: TBQueue SMPClientAgentEvent, randomDrg :: TVar ChaChaDRG, @@ -105,8 +106,7 @@ data SMPClientAgent = SMPClientAgent smpSessions :: TMap SessionId SMPClient, srvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), pendingSrvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), - reconnections :: TVar [Async ()], - asyncClients :: TVar [Async ()], + smpSubWorkers :: TMap SMPServer (SessionVar (Async ())), workerSeq :: TVar Int } @@ -137,18 +137,19 @@ instance Exception e => MonadUnliftIO (ExceptT e (ReaderT r IO)) where newSMPClientAgent :: SMPClientAgentConfig -> TVar ChaChaDRG -> STM SMPClientAgent newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg = do + active <- newTVar True msgQ <- newTBQueue msgQSize agentQ <- newTBQueue agentQSize smpClients <- TM.empty smpSessions <- TM.empty srvSubs <- TM.empty pendingSrvSubs <- TM.empty - reconnections <- newTVar [] - asyncClients <- newTVar [] + smpSubWorkers <- TM.empty workerSeq <- newTVar 0 pure SMPClientAgent { agentCfg, + active, msgQ, agentQ, randomDrg, @@ -156,14 +157,14 @@ newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg smpSessions, srvSubs, pendingSrvSubs, - reconnections, - asyncClients, + smpSubWorkers, workerSeq } +-- | Get or create SMP client for SMPServer getSMPServerClient' :: SMPClientAgent -> SMPServer -> ExceptT SMPClientError IO SMPClient -getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, randomDrg, workerSeq} srv = - atomically getClientVar >>= either newSMPClient waitForSMPClient +getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, workerSeq} srv = + atomically getClientVar >>= either (ExceptT . newSMPClient) waitForSMPClient where getClientVar :: STM (Either SMPClientVar SMPClientVar) getClientVar = getSessVar workerSeq srv smpClients @@ -182,48 +183,39 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, (throwE e) Nothing -> throwE PCEResponseTimeout - newSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient - newSMPClient v = tryConnectClient pure (liftIO tryConnectAsync) - where - tryConnectClient :: (SMPClient -> ExceptT SMPClientError IO a) -> ExceptT SMPClientError IO () -> ExceptT SMPClientError IO a - tryConnectClient successAction retryAction = - tryE (connectClient v) >>= \r -> case r of - Right smp -> do - logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv - atomically $ do - putTMVar (sessionVar v) (Right smp) - TM.insert (sessionId $ thParams smp) smp smpSessions - successAction smp - Left e -> do - if - | e == PCENetworkError || e == PCEResponseTimeout -> retryAction - | persistErrorInterval agentCfg == 0 -> do - atomically $ do - putTMVar (sessionVar v) (Left (e, Nothing)) - removeSessVar v srv smpClients - | otherwise -> do - ts <- addUTCTime (persistErrorInterval agentCfg) <$> liftIO getCurrentTime - atomically $ putTMVar (sessionVar v) (Left (e, Just ts)) - throwE e - tryConnectAsync :: IO () - tryConnectAsync = do - a <- async $ void $ runExceptT connectAsync - atomically $ modifyTVar' (asyncClients ca) (a :) - connectAsync :: ExceptT SMPClientError IO () - connectAsync = - withRetryInterval (reconnectInterval agentCfg) $ \_ loop -> - void $ tryConnectClient (const reconnectClient) loop + newSMPClient :: SMPClientVar -> IO (Either SMPClientError SMPClient) + newSMPClient v = do + r <- connectClient ca srv v `E.catch` (pure . Left . PCEIOError) + case r of + Right smp -> do + logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv + atomically $ do + putTMVar (sessionVar v) (Right smp) + TM.insert (sessionId $ thParams smp) smp smpSessions + notify ca $ CAConnected srv + Left e -> do + if persistErrorInterval agentCfg == 0 || e == PCENetworkError || e == PCEResponseTimeout + then atomically $ do + putTMVar (sessionVar v) (Left (e, Nothing)) + removeSessVar v srv smpClients + else do + ts <- addUTCTime (persistErrorInterval agentCfg) <$> liftIO getCurrentTime + atomically $ putTMVar (sessionVar v) (Left (e, Just ts)) + reconnectClient ca srv + pure r - connectClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient - connectClient v = ExceptT $ getProtocolClient randomDrg (1, srv, Nothing) (smpCfg agentCfg) (Just msgQ) (clientDisconnected v) - - clientDisconnected :: SMPClientVar -> SMPClient -> IO () - clientDisconnected v smp = do - removeClientAndSubs v smp >>= (`forM_` serverDown) +-- | Run an SMP client for SMPClientVar +connectClient :: SMPClientAgent -> SMPServer -> SMPClientVar -> IO (Either SMPClientError SMPClient) +connectClient ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, randomDrg} srv v = + getProtocolClient randomDrg (1, srv, Nothing) (smpCfg agentCfg) (Just msgQ) clientDisconnected + where + clientDisconnected :: SMPClient -> IO () + clientDisconnected smp = do + removeClientAndSubs smp >>= (`forM_` serverDown) logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv - removeClientAndSubs :: SMPClientVar -> SMPClient -> IO (Maybe (Map SMPSub C.APrivateAuthKey)) - removeClientAndSubs v smp = atomically $ do + removeClientAndSubs :: SMPClient -> IO (Maybe (Map SMPSub C.APrivateAuthKey)) + removeClientAndSubs smp = atomically $ do removeSessVar v srv smpClients TM.delete (sessionId $ thParams smp) smpSessions TM.lookupDelete srv (srvSubs ca) >>= mapM updateSubs @@ -241,63 +233,82 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, serverDown :: Map SMPSub C.APrivateAuthKey -> IO () serverDown ss = unless (M.null ss) $ do - notify . CADisconnected srv $ M.keysSet ss - reconnectServer + notify ca . CADisconnected srv $ M.keysSet ss + reconnectClient ca srv - reconnectServer :: IO () - reconnectServer = do - a <- async $ void $ runExceptT tryReconnectClient - atomically $ modifyTVar' (reconnections ca) (a :) +-- | Spawn reconnect worker if needed +reconnectClient :: SMPClientAgent -> SMPServer -> IO () +reconnectClient ca@SMPClientAgent {active, agentCfg, smpSubWorkers, workerSeq} srv = + whenM (readTVarIO active) $ atomically getWorkerVar >>= mapM_ (either newSubWorker (\_ -> pure ())) + where + getWorkerVar = + ifM + (null <$> getPending) + (pure Nothing) -- prevent race with cleanup and adding pending queues in another call + (Just <$> getSessVar workerSeq srv smpSubWorkers) + newSubWorker :: SessionVar (Async ()) -> IO () + newSubWorker v = do + a <- async $ void (E.tryAny runSubWorker) >> atomically (cleanup v) + atomically $ putTMVar (sessionVar v) a + runSubWorker = + withRetryInterval (reconnectInterval agentCfg) $ \_ loop -> do + pending <- atomically getPending + forM_ pending $ \cs -> whenM (readTVarIO active) $ do + void $ tcpConnectTimeout `timeout` runExceptT (reconnectSMPClient ca srv cs) + loop + ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout}} = smpCfg agentCfg + getPending = mapM readTVar =<< TM.lookup srv (pendingSrvSubs ca) + cleanup :: SessionVar (Async ()) -> STM () + cleanup v = do + -- Here we wait until TMVar is not empty to prevent worker cleanup happening before worker is added to TMVar. + -- Not waiting may result in terminated worker remaining in the map. + whenM (isEmptyTMVar $ sessionVar v) retry + removeSessVar v srv smpSubWorkers - tryReconnectClient :: ExceptT SMPClientError IO () - tryReconnectClient = do - withRetryInterval (reconnectInterval agentCfg) $ \_ loop -> - reconnectClient `catchE` const loop - - reconnectClient :: ExceptT SMPClientError IO () - reconnectClient = do - withSMP ca srv $ \smp -> do - liftIO $ notify $ CAReconnected srv - cs_ <- atomically $ mapM readTVar =<< TM.lookup srv (pendingSrvSubs ca) - forM_ cs_ $ \cs -> do - subs' <- filterM (fmap not . atomically . hasSub (srvSubs ca) srv . fst) $ M.assocs cs - let (nSubs, rSubs) = partition (isNotifier . fst . fst) subs' - subscribe_ smp SPNotifier nSubs - subscribe_ smp SPRecipient rSubs +reconnectSMPClient :: SMPClientAgent -> SMPServer -> Map SMPSub C.APrivateAuthKey -> ExceptT SMPClientError IO () +reconnectSMPClient ca@SMPClientAgent {agentCfg} srv cs = + withSMP ca srv $ \smp -> do + subs' <- filterM (fmap not . atomically . hasSub (srvSubs ca) srv . fst) $ M.assocs cs + let (nSubs, rSubs) = partition (isNotifier . fst . fst) subs' + subscribe_ smp SPNotifier nSubs + subscribe_ smp SPRecipient rSubs + where + isNotifier = \case + SPNotifier -> True + SPRecipient -> False + subscribe_ :: SMPClient -> SMPSubParty -> [(SMPSub, C.APrivateAuthKey)] -> ExceptT SMPClientError IO () + subscribe_ smp party = mapM_ subscribeBatch . toChunks (agentSubsBatchSize agentCfg) where - isNotifier = \case - SPNotifier -> True - SPRecipient -> False + subscribeBatch subs' = do + let subs'' :: (NonEmpty (QueueId, C.APrivateAuthKey)) = L.map (first snd) subs' + rs <- liftIO $ smpSubscribeQueues party ca smp srv subs'' + let rs' :: (NonEmpty ((SMPSub, C.APrivateAuthKey), Either SMPClientError ())) = + L.zipWith (first . const) subs' rs + rs'' :: [Either (SMPSub, SMPClientError) (SMPSub, C.APrivateAuthKey)] = + map (\(sub, r) -> bimap (fst sub,) (const sub) r) $ L.toList rs' + (errs, oks) = partitionEithers rs'' + (tempErrs, finalErrs) = partition (temporaryClientError . snd) errs + mapM_ (atomically . addSubscription ca srv) oks + mapM_ (notify ca . CAResubscribed srv) $ L.nonEmpty $ map fst oks + mapM_ (atomically . removePendingSubscription ca srv . fst) finalErrs + mapM_ (notify ca . CASubError srv) $ L.nonEmpty finalErrs + mapM_ (throwE . snd) $ listToMaybe tempErrs - subscribe_ :: SMPClient -> SMPSubParty -> [(SMPSub, C.APrivateAuthKey)] -> ExceptT SMPClientError IO () - subscribe_ smp party = mapM_ subscribeBatch . toChunks (agentSubsBatchSize agentCfg) - where - subscribeBatch subs' = do - let subs'' :: (NonEmpty (QueueId, C.APrivateAuthKey)) = L.map (first snd) subs' - rs <- liftIO $ smpSubscribeQueues party ca smp srv subs'' - let rs' :: (NonEmpty ((SMPSub, C.APrivateAuthKey), Either SMPClientError ())) = - L.zipWith (first . const) subs' rs - rs'' :: [Either (SMPSub, SMPClientError) (SMPSub, C.APrivateAuthKey)] = - map (\(sub, r) -> bimap (fst sub,) (const sub) r) $ L.toList rs' - (errs, oks) = partitionEithers rs'' - (tempErrs, finalErrs) = partition (temporaryClientError . snd) errs - mapM_ (atomically . addSubscription ca srv) oks - mapM_ (liftIO . notify . CAResubscribed srv) $ L.nonEmpty $ map fst oks - mapM_ (atomically . removePendingSubscription ca srv . fst) finalErrs - mapM_ (liftIO . notify . CASubError srv) $ L.nonEmpty finalErrs - mapM_ (throwE . snd) $ listToMaybe tempErrs - - notify :: SMPClientAgentEvent -> IO () - notify evt = atomically $ writeTBQueue (agentQ ca) evt +notify :: MonadIO m => SMPClientAgent -> SMPClientAgentEvent -> m () +notify ca evt = atomically $ writeTBQueue (agentQ ca) evt +{-# INLINE notify #-} lookupSMPServerClient :: SMPClientAgent -> SessionId -> STM (Maybe SMPClient) lookupSMPServerClient SMPClientAgent {smpSessions} sessId = TM.lookup sessId smpSessions closeSMPClientAgent :: SMPClientAgent -> IO () closeSMPClientAgent c = do + atomically $ writeTVar (active c) False closeSMPServerClients c - cancelActions $ reconnections c - cancelActions $ asyncClients c + atomically (swapTVar (smpSubWorkers c) M.empty) >>= mapM_ cancelReconnect + where + cancelReconnect :: SessionVar (Async ()) -> IO () + cancelReconnect v = void . forkIO $ atomically (readTMVar $ sessionVar v) >>= uninterruptibleCancel closeSMPServerClients :: SMPClientAgent -> IO () closeSMPServerClients c = atomically (smpClients c `swapTVar` M.empty) >>= mapM_ (forkIO . closeClient) diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index 37eff1e94..5e4cf839e 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -98,7 +98,9 @@ ntfServer cfg@NtfServerConfig {transports, transportConfig = tCfg} started = do stopServer = do withNtfLog closeStoreLog saveServerStats - asks (smpSubscribers . subscriber) >>= readTVarIO >>= mapM_ (\SMPSubscriber {subThreadId} -> readTVarIO subThreadId >>= mapM_ (liftIO . deRefWeak >=> mapM_ killThread)) + NtfSubscriber {smpSubscribers, smpAgent} <- asks subscriber + liftIO $ readTVarIO smpSubscribers >>= mapM_ (\SMPSubscriber {subThreadId} -> readTVarIO subThreadId >>= mapM_ (deRefWeak >=> mapM_ killThread)) + liftIO $ closeSMPClientAgent smpAgent serverStatsThread_ :: NtfServerConfig -> [M ()] serverStatsThread_ NtfServerConfig {logStatsInterval = Just interval, logStatsStartTime, serverStatsLogFile} = @@ -239,14 +241,14 @@ ntfSubscriber NtfSubscriber {smpSubscribers, newSubQ, smpAgent = ca@SMPClientAge receiveAgent = forever $ atomically (readTBQueue agentQ) >>= \case - CAConnected _ -> pure () + CAConnected srv -> + logInfo $ "SMP server reconnected " <> showServer' srv CADisconnected srv subs -> do logSubStatus srv "disconnected" $ length subs forM_ subs $ \(_, ntfId) -> do let smpQueue = SMPQueueNtf srv ntfId updateSubStatus smpQueue NSInactive - CAReconnected srv -> - logInfo $ "SMP server reconnected " <> showServer' srv + CAResubscribed srv subs -> do forM_ subs $ \(_, ntfId) -> updateSubStatus (SMPQueueNtf srv ntfId) NSActive logSubStatus srv "resubscribed" $ length subs diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 33163664a..6c8a18aa5 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -69,7 +69,7 @@ import GHC.TypeLits (KnownNat) import Network.Socket (ServiceName, Socket, socketToHandle) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Client (ProtocolClient (thParams), ProtocolClientError (..), forwardSMPMessage, smpProxyError) -import Simplex.Messaging.Client.Agent (SMPClientAgent (..), SMPClientAgentEvent (..), getSMPServerClient', lookupSMPServerClient) +import Simplex.Messaging.Client.Agent (SMPClientAgent (..), SMPClientAgentEvent (..), closeSMPClientAgent, getSMPServerClient', lookupSMPServerClient) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -136,7 +136,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do : receiveFromProxyAgent pa : map runServer transports <> expireMessagesThread_ cfg <> serverStatsThread_ cfg <> controlPortThread_ cfg ) - `finally` withLock' (savingLock s) "final" (saveServer False) + `finally` withLock' (savingLock s) "final" (saveServer False >> closeServer) where runServer :: (ServiceName, ATransport) -> M () runServer (tcpPort, ATransport t) = do @@ -150,6 +150,9 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do saveServer :: Bool -> M () saveServer keepMsgs = withLog closeStoreLog >> saveServerMessages keepMsgs >> saveServerStats + closeServer :: M () + closeServer = asks (smpAgent . proxyAgent) >>= liftIO . closeSMPClientAgent + serverThread :: forall s. Server -> @@ -193,7 +196,6 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do CAConnected srv -> logInfo $ "SMP server connected " <> showServer' srv CADisconnected srv [] -> logInfo $ "SMP server disconnected " <> showServer' srv CADisconnected srv subs -> logError $ "SMP server disconnected " <> showServer' srv <> " / subscriptions: " <> tshow (length subs) - CAReconnected srv -> logInfo $ "SMP server reconnected " <> showServer' srv CAResubscribed srv subs -> logError $ "SMP server resubscribed " <> showServer' srv <> " / subscriptions: " <> tshow (length subs) CASubError srv errs -> logError $ "SMP server subscription errors " <> showServer' srv <> " / errors: " <> tshow (length errs) where @@ -658,7 +660,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi Just (Compatible vr) | thVersion >= sendingProxySMPVersion -> case thAuth of Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey Nothing -> ERR $ transportErr TENoServerAuth - _ -> ERR $ transportErr TEVersion + _ -> ERR $ transportErr TEVersion PFWD fwdV pubKey encBlock -> do ProxyAgent {smpAgent} <- asks proxyAgent atomically (lookupSMPServerClient smpAgent sessId) >>= \case From b33e8f4370fb22c31395736250fa016b7a844b90 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 17 May 2024 17:23:59 +0400 Subject: [PATCH 062/107] agent: reconnect xftp clients (#1156) --- src/Simplex/Messaging/Agent.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index f93d03e35..03dd22901 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -445,6 +445,7 @@ setUserNetworkInfo c@AgentClient {userNetworkState} UserNetworkInfo {networkType reconnectAllServers :: AgentClient -> IO () reconnectAllServers c = do reconnectServerClients c smpClients + reconnectServerClients c xftpClients reconnectServerClients c ntfClients -- | Register device notifications token From 33f6d2f1da614851fe04fc40e9bd5c7a43d8e3b5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 17 May 2024 15:12:05 +0100 Subject: [PATCH 063/107] agent: optimize waiting for user network to avoid contention for state updates from multiple threads (#1155) * haskell magic * update * agent: optimize waiting for user network with TChan * clean up * Int64 * use TVar * cleanup * fix * testing * update --- src/Simplex/FileTransfer/Agent.hs | 8 +- src/Simplex/Messaging/Agent.hs | 32 ++--- src/Simplex/Messaging/Agent/Client.hs | 109 ++++++++---------- .../Messaging/Agent/NtfSubSupervisor.hs | 4 +- src/Simplex/Messaging/Util.hs | 14 ++- tests/AgentTests/FunctionalAPITests.hs | 37 +++++- 6 files changed, 115 insertions(+), 89 deletions(-) diff --git a/src/Simplex/FileTransfer/Agent.hs b/src/Simplex/FileTransfer/Agent.hs index d04117942..6e948287f 100644 --- a/src/Simplex/FileTransfer/Agent.hs +++ b/src/Simplex/FileTransfer/Agent.hs @@ -180,7 +180,7 @@ runXFTPRcvWorker c srv Worker {doWork} = do fc@RcvFileChunk {userId, rcvFileId, rcvFileEntityId, digest, fileTmpPath, replicas = replica@RcvFileChunkReplica {rcvChunkReplicaId, server, delay} : _} -> do let ri' = maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) delay withRetryIntervalLimit xftpConsecutiveRetries ri' $ \delay' loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c downloadFileChunk fc replica `catchAgentError` \e -> retryOnError "XFTP rcv worker" (retryLoop loop e delay') (retryDone e) e where @@ -424,7 +424,7 @@ runXFTPSndPrepareWorker c Worker {doWork} = do tryCreate = do usedSrvs <- newTVarIO ([] :: [XFTPServer]) withRetryInterval (riFast ri) $ \_ loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c createWithNextSrv usedSrvs `catchAgentError` \e -> retryOnError "XFTP prepare worker" (retryLoop loop) (throwError e) e where @@ -457,7 +457,7 @@ runXFTPSndWorker c srv Worker {doWork} = do fc@SndFileChunk {userId, sndFileId, sndFileEntityId, filePrefixPath, digest, replicas = replica@SndFileChunkReplica {sndChunkReplicaId, server, delay} : _} -> do let ri' = maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) delay withRetryIntervalLimit xftpConsecutiveRetries ri' $ \delay' loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c uploadFileChunk cfg fc replica `catchAgentError` \e -> retryOnError "XFTP snd worker" (retryLoop loop e delay') (retryDone e) e where @@ -624,7 +624,7 @@ runXFTPDelWorker c srv Worker {doWork} = do processDeletedReplica replica@DeletedSndChunkReplica {deletedSndChunkReplicaId, userId, server, chunkDigest, delay} = do let ri' = maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) delay withRetryIntervalLimit xftpConsecutiveRetries ri' $ \delay' loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c deleteChunkReplica `catchAgentError` \e -> retryOnError "XFTP del worker" (retryLoop loop e delay') (retryDone e) e where diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 03dd22901..12663362d 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -429,18 +429,24 @@ getNetworkConfig = fmap snd . readTVarIO . useNetworkConfig {-# INLINE getNetworkConfig #-} setUserNetworkInfo :: AgentClient -> UserNetworkInfo -> IO () -setUserNetworkInfo c@AgentClient {userNetworkState} UserNetworkInfo {networkType = nt', online} = withAgentEnv' c $ do - d <- asks $ initialInterval . userNetworkInterval . config - ts <- liftIO getCurrentTime - atomically $ do - ns@UserNetworkState {networkType = nt, offline} <- readTVar userNetworkState - when (nt' /= nt || online /= isNothing offline) $ - writeTVar userNetworkState $! - let offline' - | nt' /= UNNone && online = Nothing - | isJust offline = offline - | otherwise = Just UNSOffline {offlineDelay = d, offlineFrom = ts} - in ns {networkType = nt', offline = offline'} +setUserNetworkInfo c@AgentClient {userNetworkInfo, userNetworkDelay} netInfo = withAgentEnv' c $ do + ni <- asks $ userNetworkInterval . config + let d = initialInterval ni + off <- atomically $ do + wasOnline <- isOnline <$> swapTVar userNetworkInfo netInfo + let off = wasOnline && not (isOnline netInfo) + when off $ writeTVar userNetworkDelay d + pure off + liftIO . when off . void . forkIO $ + growOfflineDelay 0 d ni + where + growOfflineDelay elapsed d ni = do + online <- waitOnlineOrDelay c d + unless online $ do + let elapsed' = elapsed + d + d' = nextRetryDelay elapsed' d ni + atomically $ writeTVar userNetworkDelay d' + growOfflineDelay elapsed' d' ni reconnectAllServers :: AgentClient -> IO () reconnectAllServers c = do @@ -1318,7 +1324,7 @@ runSmpQueueMsgDelivery c@AgentClient {subQ} ConnData {connId} sq (Worker {doWork let mId = unId msgId ri' = maybe id updateRetryInterval2 msgRetryState ri withRetryLock2 ri' qLock $ \riState loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c resp <- tryError $ case msgType of AM_CONN_INFO -> sendConfirmation c sq msgBody AM_CONN_INFO_REPLY -> sendConfirmation c sq msgBody diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index cfed6b932..1720b2c0f 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -103,9 +103,10 @@ module Simplex.Messaging.Agent.Client waitUntilActive, UserNetworkInfo (..), UserNetworkType (..), - UserNetworkState (..), - UNSOffline (..), waitForUserNetwork, + waitOnlineOrDelay, + isNetworkOnline, + isOnline, throwWhenInactive, throwWhenNoDelivery, beginAgentOperation, @@ -165,7 +166,7 @@ import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import Data.Text.Encoding -import Data.Time (UTCTime, defaultTimeLocale, diffUTCTime, formatTime, getCurrentTime) +import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime) import Data.Time.Clock.System (getSystemTime) import Data.Word (Word16) import Network.Socket (HostName) @@ -268,7 +269,8 @@ data AgentClient = AgentClient xftpServers :: TMap UserId (NonEmpty XFTPServerWithAuth), xftpClients :: TMap XFTPTransportSession XFTPClientVar, useNetworkConfig :: TVar (NetworkConfig, NetworkConfig), -- (slow, fast) networks - userNetworkState :: TVar UserNetworkState, + userNetworkInfo :: TVar UserNetworkInfo, + userNetworkDelay :: TVar Int64, subscrConns :: TVar (Set ConnId), activeSubs :: TRcvQueues, pendingSubs :: TRcvQueues, @@ -405,22 +407,20 @@ data UserNetworkInfo = UserNetworkInfo } deriving (Show) +isNetworkOnline :: AgentClient -> STM Bool +isNetworkOnline c = isOnline <$> readTVar (userNetworkInfo c) + +isOnline :: UserNetworkInfo -> Bool +isOnline UserNetworkInfo {networkType, online} = networkType /= UNNone && online + data UserNetworkType = UNNone | UNCellular | UNWifi | UNEthernet | UNOther deriving (Eq, Show) -data UserNetworkState = UserNetworkState - { networkType :: UserNetworkType, - offline :: Maybe UNSOffline - } - deriving (Show) - -data UNSOffline = UNSOffline {offlineDelay :: Int64, offlineFrom :: UTCTime} - deriving (Show) - -- | Creates an SMP agent client instance that receives commands and sends responses via 'TBQueue's. newAgentClient :: Int -> InitialAgentServers -> Env -> STM AgentClient newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = do - let qSize = tbqSize $ config agentEnv + let cfg = config agentEnv + qSize = tbqSize cfg acThread <- newTVar Nothing active <- newTVar True rcvQ <- newTBQueue qSize @@ -433,7 +433,8 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = xftpServers <- newTVar xftp xftpClients <- TM.empty useNetworkConfig <- newTVar (slowNetworkConfig netCfg, netCfg) - userNetworkState <- newTVar $ UserNetworkState UNOther Nothing + userNetworkInfo <- newTVar $ UserNetworkInfo UNOther True + userNetworkDelay <- newTVar $ initialInterval $ userNetworkInterval cfg subscrConns <- newTVar S.empty activeSubs <- RQ.empty pendingSubs <- RQ.empty @@ -468,7 +469,8 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = xftpServers, xftpClients, useNetworkConfig, - userNetworkState, + userNetworkInfo, + userNetworkDelay, subscrConns, activeSubs, pendingSubs, @@ -612,7 +614,7 @@ resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = withRetryInterval ri $ \_ loop -> do pending <- atomically getPending forM_ (L.nonEmpty pending) $ \qs -> do - waitForUserNetwork c + lift $ waitForUserNetwork c void . tryAgentError' $ reconnectSMPClient timeoutCounts c tSess qs loop getPending = RQ.getSessQueues tSess $ pendingSubs c @@ -629,18 +631,17 @@ reconnectSMPClient tc c tSess@(_, srv, _) qs = do -- this allows 3x of timeout per batch of subscription (90 queues per batch empirically) let t = (length qs `div` 90 + 1) * tcpTimeout * 3 ExceptT (sequence <$> (t `timeout` runExceptT resubscribe)) >>= \case - Just _ -> atomically $ writeTVar tc 0 - Nothing -> - (offline <$> readTVarIO (userNetworkState c)) >>= \case - -- reset and do not report consequitive timeouts while offline - Just _ -> atomically $ writeTVar tc 0 - Nothing -> do - tc' <- atomically $ stateTVar tc $ \i -> (i + 1, i + 1) - maxTC <- asks $ maxSubscriptionTimeouts . config - when (tc' >= maxTC) $ do - let msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess - atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ INTERNAL msg) + Just _ -> resetTimeouts + -- reset and do not report consecutive timeouts while offline + Nothing -> ifM (atomically $ isNetworkOnline c) notifyTimeout resetTimeouts where + resetTimeouts = atomically $ writeTVar tc 0 + notifyTimeout = do + tc' <- atomically $ stateTVar tc $ \i -> (i + 1, i + 1) + maxTC <- asks $ maxSubscriptionTimeouts . config + when (tc' >= maxTC) $ do + let msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess + atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ INTERNAL msg) resubscribe :: AM () resubscribe = do cs <- readTVarIO $ RQ.getConnections $ activeSubs c @@ -750,43 +751,31 @@ getClientConfig c cfgSel = do getNetworkConfig :: AgentClient -> STM NetworkConfig getNetworkConfig c = do (slowCfg, fastCfg) <- readTVar (useNetworkConfig c) - UserNetworkState {networkType} <- readTVar (userNetworkState c) + UserNetworkInfo {networkType} <- readTVar $ userNetworkInfo c pure $ case networkType of UNCellular -> slowCfg UNNone -> slowCfg _ -> fastCfg -waitForUserNetwork :: AgentClient -> AM' () -waitForUserNetwork AgentClient {userNetworkState} = - readTVarIO userNetworkState >>= mapM_ waitWhileOffline . offline - where - waitWhileOffline UNSOffline {offlineDelay = d} = - unlessM (liftIO $ waitOnline d False) $ do - -- network delay reached, increase delay - ts' <- liftIO getCurrentTime - ni <- asks $ userNetworkInterval . config - atomically $ do - ns@UserNetworkState {offline} <- readTVar userNetworkState - forM_ offline $ \UNSOffline {offlineDelay = d', offlineFrom = ts} -> - -- Using `min` to avoid multiple updates in a short period of time - -- and to reset `offlineDelay` if network went `on` and `off` again. - writeTVar userNetworkState $! - let d'' = nextRetryDelay (diffToMicroseconds $ diffUTCTime ts' ts) (min d d') ni - in ns {offline = Just UNSOffline {offlineDelay = d'', offlineFrom = ts}} - waitOnline :: Int64 -> Bool -> IO Bool - waitOnline t online' - | t <= 0 = pure online' - | otherwise = - registerDelay (fromIntegral maxWait) - >>= atomically . onlineOrDelay - >>= waitOnline (t - maxWait) - where - maxWait = min t $ fromIntegral (maxBound :: Int) - onlineOrDelay delay = do - online <- isNothing . offline <$> readTVar userNetworkState - expired <- readTVar delay - unless (online || expired) retry - pure online +waitForUserNetwork :: AgentClient -> IO () +waitForUserNetwork c = + unlessM (atomically $ isNetworkOnline c) $ + readTVarIO (userNetworkDelay c) >>= void . waitOnlineOrDelay c + +waitOnlineOrDelay :: AgentClient -> Int64 -> IO Bool +waitOnlineOrDelay c t = do + let maxWait = min t $ fromIntegral (maxBound :: Int) + t' = t - maxWait + delay <- registerDelay $ fromIntegral maxWait + online <- + atomically $ do + expired <- readTVar delay + online <- isNetworkOnline c + unless (expired || online) retry + pure online + if online || t' <= 0 + then pure online + else waitOnlineOrDelay c t' closeAgentClient :: AgentClient -> IO () closeAgentClient c = do diff --git a/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs b/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs index ae0066328..4aaa5f278 100644 --- a/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs +++ b/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs @@ -161,7 +161,7 @@ runNtfWorker c srv Worker {doWork} = do logInfo $ "runNtfWorker, nextSub " <> tshow nextSub ri <- asks $ reconnectInterval . config withRetryInterval ri $ \_ loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c processSub nextSub `catchAgentError` retryOnError c "NtfWorker" loop (workerInternalError c connId . show) processSub :: (NtfSubscription, NtfSubNTFAction, NtfActionTs) -> AM () @@ -245,7 +245,7 @@ runNtfSMPWorker c srv Worker {doWork} = do logInfo $ "runNtfSMPWorker, nextSub " <> tshow nextSub ri <- asks $ reconnectInterval . config withRetryInterval ri $ \_ loop -> do - lift $ waitForUserNetwork c + liftIO $ waitForUserNetwork c processSub nextSub `catchAgentError` retryOnError c "NtfSMPWorker" loop (workerInternalError c connId . show) processSub :: (NtfSubscription, NtfSubSMPAction, NtfActionTs) -> AM () diff --git a/src/Simplex/Messaging/Util.hs b/src/Simplex/Messaging/Util.hs index a880cfaad..b42d5b378 100644 --- a/src/Simplex/Messaging/Util.hs +++ b/src/Simplex/Messaging/Util.hs @@ -152,12 +152,14 @@ timeoutThrow :: MonadUnliftIO m => e -> Int -> ExceptT e m a -> ExceptT e m a timeoutThrow e ms action = ExceptT (sequence <$> (ms `timeout` runExceptT action)) >>= maybe (throwError e) pure threadDelay' :: Int64 -> IO () -threadDelay' time - | time <= 0 = pure () -threadDelay' time = do - let maxWait = min time $ fromIntegral (maxBound :: Int) - threadDelay $ fromIntegral maxWait - when (maxWait /= time) $ threadDelay' (time - maxWait) +threadDelay' = loop + where + loop time + | time <= 0 = pure () + | otherwise = do + let maxWait = min time $ fromIntegral (maxBound :: Int) + threadDelay $ fromIntegral maxWait + loop $ time - maxWait diffToMicroseconds :: NominalDiffTime -> Int64 diffToMicroseconds diff = fromIntegral ((truncate $ diff * 1000000) :: Integer) diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 59e433ea5..16f96bee4 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -50,7 +50,7 @@ module AgentTests.FunctionalAPITests where import AgentTests.ConnectionRequestTests (connReqData, queueAddr, testE2ERatchetParams12) -import Control.Concurrent (killThread, threadDelay) +import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Monad import Control.Monad.Except import Control.Monad.Reader @@ -436,6 +436,7 @@ functionalAPITests t = do describe "user network info" $ do it "should wait for user network" testWaitForUserNetwork it "should not reset offline interval while offline" testDoNotResetOfflineInterval + it "should resume multiple threads" testResumeMultipleThreads testBasicAuth :: ATransport -> Bool -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> IO Int testBasicAuth t allowNewQueues srv@(srvAuth, srvVersion) clnt1 clnt2 = do @@ -2697,6 +2698,7 @@ testWaitForUserNetwork = do a <- getSMPAgentClient' 1 aCfg initAgentServers testDB noNetworkDelay a setUserNetworkInfo a $ UserNetworkInfo UNNone False + threadDelay 5000 networkDelay a 100000 networkDelay a 150000 networkDelay a 200000 @@ -2717,6 +2719,7 @@ testDoNotResetOfflineInterval = do a <- getSMPAgentClient' 1 aCfg initAgentServers testDB noNetworkDelay a setUserNetworkInfo a $ UserNetworkInfo UNWifi False + threadDelay 5000 networkDelay a 100000 networkDelay a 150000 setUserNetworkInfo a $ UserNetworkInfo UNCellular False @@ -2730,16 +2733,42 @@ testDoNotResetOfflineInterval = do where aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 200000}} +testResumeMultipleThreads :: IO () +testResumeMultipleThreads = do + a <- getSMPAgentClient' 1 aCfg initAgentServers testDB + noNetworkDelay a + setUserNetworkInfo a $ UserNetworkInfo UNNone False + vs <- + replicateM 50000 $ do + v <- newEmptyTMVarIO + void . forkIO $ waitNetwork a >>= atomically . putTMVar v + pure v + threadDelay 1000000 + setUserNetworkInfo a $ UserNetworkInfo UNCellular True + ts <- mapM (atomically . readTMVar) vs + print $ minimum ts + print $ maximum ts + print $ sum ts `div` fromIntegral (length ts) + let average = sum ts `div` fromIntegral (length ts) + average < 3000000 `shouldBe` True + maximum ts < 4000000 `shouldBe` True + where + aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 1000000, increaseAfter = 0, maxInterval = 3600_000_000}} + noNetworkDelay :: AgentClient -> IO () -noNetworkDelay a = (10000 >) <$> waitNetwork a `shouldReturn` True +noNetworkDelay a = do + d <- waitNetwork a + unless (d < 10000) $ expectationFailure $ "expected no delay, d = " <> show d networkDelay :: AgentClient -> Int64 -> IO () -networkDelay a d' = (\d -> d' < d && d < d' + 15000) <$> waitNetwork a `shouldReturn` True +networkDelay a d' = do + d <- waitNetwork a + unless (d' < d && d < d' + 15000) $ expectationFailure $ "expected delay " <> show d' <> ", d = " <> show d waitNetwork :: AgentClient -> IO Int64 waitNetwork a = do t <- getCurrentTime - waitForUserNetwork a `runReaderT` agentEnv a + waitForUserNetwork a t' <- getCurrentTime pure $ diffToMicroseconds $ diffUTCTime t' t From 1116aeeea1869e0de38e9faccea76b329b549804 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 17 May 2024 15:39:20 +0100 Subject: [PATCH 064/107] 5.8.0.1 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 0aed0c806..6b90c709f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.0 +version: 5.8.0.1 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index bd0883c23..d077deaae 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.0 +version: 5.8.0.1 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 1bb6a5c43b491f026adcbe87cf08df202ef72a05 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 19 May 2024 07:50:47 +0100 Subject: [PATCH 065/107] agent: do not increase network activity interval while offline (#1159) * agent: do not increase network activity interval while offline * test --- src/Simplex/Messaging/Agent.hs | 26 +++++--------- src/Simplex/Messaging/Agent/Client.hs | 28 ++++----------- src/Simplex/Messaging/Agent/Env/SQLite.hs | 16 +++------ tests/AgentTests/FunctionalAPITests.hs | 43 +++++++++++------------ 4 files changed, 41 insertions(+), 72 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 12663362d..652e2d58d 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -429,24 +429,16 @@ getNetworkConfig = fmap snd . readTVarIO . useNetworkConfig {-# INLINE getNetworkConfig #-} setUserNetworkInfo :: AgentClient -> UserNetworkInfo -> IO () -setUserNetworkInfo c@AgentClient {userNetworkInfo, userNetworkDelay} netInfo = withAgentEnv' c $ do - ni <- asks $ userNetworkInterval . config - let d = initialInterval ni - off <- atomically $ do - wasOnline <- isOnline <$> swapTVar userNetworkInfo netInfo - let off = wasOnline && not (isOnline netInfo) - when off $ writeTVar userNetworkDelay d - pure off - liftIO . when off . void . forkIO $ - growOfflineDelay 0 d ni +setUserNetworkInfo c@AgentClient {userNetworkInfo, userNetworkUpdated} ni = withAgentEnv' c $ do + ts' <- liftIO getCurrentTime + i <- asks $ userOfflineDelay . config + -- if network offline event happens in less than `userOfflineDelay` after the previous event, it is ignored + atomically . whenM ((isOnline ni ||) <$> notRecentlyChanged ts' i) $ do + writeTVar userNetworkInfo ni + writeTVar userNetworkUpdated $ Just ts' where - growOfflineDelay elapsed d ni = do - online <- waitOnlineOrDelay c d - unless online $ do - let elapsed' = elapsed + d - d' = nextRetryDelay elapsed' d ni - atomically $ writeTVar userNetworkDelay d' - growOfflineDelay elapsed' d' ni + notRecentlyChanged ts' i = + maybe True (\ts -> diffUTCTime ts' ts > i) <$> readTVar userNetworkUpdated reconnectAllServers :: AgentClient -> IO () reconnectAllServers c = do diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 1720b2c0f..2299b2183 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -104,7 +104,6 @@ module Simplex.Messaging.Agent.Client UserNetworkInfo (..), UserNetworkType (..), waitForUserNetwork, - waitOnlineOrDelay, isNetworkOnline, isOnline, throwWhenInactive, @@ -155,7 +154,6 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (lefts, partitionEithers) import Data.Functor (($>)) -import Data.Int (Int64) import Data.List (deleteFirstsBy, foldl', partition, (\\)) import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L @@ -270,7 +268,7 @@ data AgentClient = AgentClient xftpClients :: TMap XFTPTransportSession XFTPClientVar, useNetworkConfig :: TVar (NetworkConfig, NetworkConfig), -- (slow, fast) networks userNetworkInfo :: TVar UserNetworkInfo, - userNetworkDelay :: TVar Int64, + userNetworkUpdated :: TVar (Maybe UTCTime), subscrConns :: TVar (Set ConnId), activeSubs :: TRcvQueues, pendingSubs :: TRcvQueues, @@ -434,7 +432,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = xftpClients <- TM.empty useNetworkConfig <- newTVar (slowNetworkConfig netCfg, netCfg) userNetworkInfo <- newTVar $ UserNetworkInfo UNOther True - userNetworkDelay <- newTVar $ initialInterval $ userNetworkInterval cfg + userNetworkUpdated <- newTVar Nothing subscrConns <- newTVar S.empty activeSubs <- RQ.empty pendingSubs <- RQ.empty @@ -470,7 +468,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, netCfg} agentEnv = xftpClients, useNetworkConfig, userNetworkInfo, - userNetworkDelay, + userNetworkUpdated, subscrConns, activeSubs, pendingSubs, @@ -759,23 +757,9 @@ getNetworkConfig c = do waitForUserNetwork :: AgentClient -> IO () waitForUserNetwork c = - unlessM (atomically $ isNetworkOnline c) $ - readTVarIO (userNetworkDelay c) >>= void . waitOnlineOrDelay c - -waitOnlineOrDelay :: AgentClient -> Int64 -> IO Bool -waitOnlineOrDelay c t = do - let maxWait = min t $ fromIntegral (maxBound :: Int) - t' = t - maxWait - delay <- registerDelay $ fromIntegral maxWait - online <- - atomically $ do - expired <- readTVar delay - online <- isNetworkOnline c - unless (expired || online) retry - pure online - if online || t' <= 0 - then pure online - else waitOnlineOrDelay c t' + unlessM (atomically $ isNetworkOnline c) $ do + delay <- registerDelay $ userNetworkInterval $ config $ agentEnv c + atomically $ unlessM (isNetworkOnline c) $ unlessM (readTVar delay) retry closeAgentClient :: AgentClient -> IO () closeAgentClient c = do diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index 07d3f29a8..9613adf3c 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -92,7 +92,8 @@ data AgentConfig = AgentConfig xftpCfg :: XFTPClientConfig, reconnectInterval :: RetryInterval, messageRetryInterval :: RetryInterval2, - userNetworkInterval :: RetryInterval, + userNetworkInterval :: Int, + userOfflineDelay :: NominalDiffTime, messageTimeout :: NominalDiffTime, connDeleteDeliveryTimeout :: NominalDiffTime, helloTimeout :: NominalDiffTime, @@ -147,14 +148,6 @@ defaultMessageRetryInterval = } } -defaultUserNetworkInterval :: RetryInterval -defaultUserNetworkInterval = - RetryInterval - { initialInterval = 1200_000000, -- 20 minutes - increaseAfter = 0, - maxInterval = 7200_000000 -- 2 hours - } - defaultAgentConfig :: AgentConfig defaultAgentConfig = AgentConfig @@ -170,7 +163,8 @@ defaultAgentConfig = xftpCfg = defaultXFTPClientConfig, reconnectInterval = defaultReconnectInterval, messageRetryInterval = defaultMessageRetryInterval, - userNetworkInterval = defaultUserNetworkInterval, + userNetworkInterval = 1800_000000, -- 30 minutes, should be less than Int32 max value + userOfflineDelay = 2, -- if network offline event happens in less than 2 seconds after it was set online, it is ignored messageTimeout = 2 * nominalDay, connDeleteDeliveryTimeout = 2 * nominalDay, helloTimeout = 2 * nominalDay, @@ -179,7 +173,7 @@ defaultAgentConfig = cleanupInterval = 30 * 60 * 1000000, -- 30 minutes cleanupStepInterval = 200000, -- 200ms maxWorkerRestartsPerMin = 5, - -- 3 consecutive subscription timeouts will result in alert to the user + -- 5 consecutive subscription timeouts will result in alert to the user -- this is a fallback, as the timeout set to 3x of expected timeout, to avoid potential locking. maxSubscriptionTimeouts = 5, storedMsgDataTTL = 21 * nominalDay, diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 16f96bee4..7cf1ab00a 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -77,7 +77,6 @@ import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestSte import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore) import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO, REQ) import qualified Simplex.Messaging.Agent.Protocol as A -import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore (dbNew)) import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction') import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientConfig (..), TransportSessionMode (TSMEntity, TSMUser), defaultClientConfig) @@ -435,7 +434,7 @@ functionalAPITests t = do it "send delivery receipts concurrently with messages" $ testDeliveryReceiptsConcurrent t describe "user network info" $ do it "should wait for user network" testWaitForUserNetwork - it "should not reset offline interval while offline" testDoNotResetOfflineInterval + it "should not reset online to offline if happens too quickly" testDoNotResetOnlineToOffline it "should resume multiple threads" testResumeMultipleThreads testBasicAuth :: ATransport -> Bool -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> IO Int @@ -2698,11 +2697,8 @@ testWaitForUserNetwork = do a <- getSMPAgentClient' 1 aCfg initAgentServers testDB noNetworkDelay a setUserNetworkInfo a $ UserNetworkInfo UNNone False - threadDelay 5000 networkDelay a 100000 - networkDelay a 150000 - networkDelay a 200000 - networkDelay a 200000 + networkDelay a 100000 setUserNetworkInfo a $ UserNetworkInfo UNCellular True noNetworkDelay a setUserNetworkInfo a $ UserNetworkInfo UNCellular False @@ -2712,26 +2708,29 @@ testWaitForUserNetwork = do (networkDelay a 50000) noNetworkDelay a where - aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 200000}} + aCfg = agentCfg {userNetworkInterval = 100000, userOfflineDelay = 0} -testDoNotResetOfflineInterval :: IO () -testDoNotResetOfflineInterval = do +testDoNotResetOnlineToOffline :: IO () +testDoNotResetOnlineToOffline = do a <- getSMPAgentClient' 1 aCfg initAgentServers testDB noNetworkDelay a setUserNetworkInfo a $ UserNetworkInfo UNWifi False - threadDelay 5000 networkDelay a 100000 - networkDelay a 150000 - setUserNetworkInfo a $ UserNetworkInfo UNCellular False - networkDelay a 200000 - setUserNetworkInfo a $ UserNetworkInfo UNNone False - networkDelay a 200000 - setUserNetworkInfo a $ UserNetworkInfo UNCellular True + setUserNetworkInfo a $ UserNetworkInfo UNWifi False + setUserNetworkInfo a $ UserNetworkInfo UNWifi True noNetworkDelay a - setUserNetworkInfo a $ UserNetworkInfo UNCellular False + setUserNetworkInfo a $ UserNetworkInfo UNWifi False -- ingnored + noNetworkDelay a + threadDelay 100000 + setUserNetworkInfo a $ UserNetworkInfo UNWifi False networkDelay a 100000 + setUserNetworkInfo a $ UserNetworkInfo UNNone False + networkDelay a 100000 + setUserNetworkInfo a $ UserNetworkInfo UNWifi True + setUserNetworkInfo a $ UserNetworkInfo UNNone False -- ingnored + noNetworkDelay a where - aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 200000}} + aCfg = agentCfg {userNetworkInterval = 100000, userOfflineDelay = 0.1} testResumeMultipleThreads :: IO () testResumeMultipleThreads = do @@ -2746,14 +2745,14 @@ testResumeMultipleThreads = do threadDelay 1000000 setUserNetworkInfo a $ UserNetworkInfo UNCellular True ts <- mapM (atomically . readTMVar) vs - print $ minimum ts - print $ maximum ts - print $ sum ts `div` fromIntegral (length ts) + -- print $ minimum ts + -- print $ maximum ts + -- print $ sum ts `div` fromIntegral (length ts) let average = sum ts `div` fromIntegral (length ts) average < 3000000 `shouldBe` True maximum ts < 4000000 `shouldBe` True where - aCfg = agentCfg {userNetworkInterval = RetryInterval {initialInterval = 1000000, increaseAfter = 0, maxInterval = 3600_000_000}} + aCfg = agentCfg {userOfflineDelay = 0} noNetworkDelay :: AgentClient -> IO () noNetworkDelay a = do From 8b21f7ef2a0e68e0107b7d1d1982ae0d9ff2f1aa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 May 2024 07:56:51 +0100 Subject: [PATCH 066/107] agent: aggregate multiple expired subscription responses into a single UP event (#1160) * agent: aggregate multiple expired subscription responses into a single UP event * clean up * refactor processing of expired responses * refactor * refactor 2 * refactor unexpectedResponse --- src/Simplex/FileTransfer/Client.hs | 14 +- src/Simplex/Messaging/Agent.hs | 133 ++++++++++-------- src/Simplex/Messaging/Agent/Client.hs | 15 +- src/Simplex/Messaging/Client.hs | 98 +++++++------ src/Simplex/Messaging/Client/Agent.hs | 2 +- src/Simplex/Messaging/Notifications/Client.hs | 11 +- src/Simplex/Messaging/Notifications/Server.hs | 41 +++--- tests/SMPProxyTests.hs | 4 +- 8 files changed, 180 insertions(+), 138 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 7875542a6..4fb18c27a 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -14,6 +14,7 @@ module Simplex.FileTransfer.Client where import Control.Logger.Simple import Control.Monad import Control.Monad.Except +import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Builder (Builder, byteString) @@ -38,6 +39,7 @@ import Simplex.Messaging.Client defaultNetworkConfig, proxyUsername, transportClientConfig, + unexpectedResponse, ) import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C @@ -56,7 +58,7 @@ import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client import Simplex.Messaging.Transport.HTTP2.File -import Simplex.Messaging.Util (bshow, liftEitherWith, liftError', tshow, whenM) +import Simplex.Messaging.Util (liftEitherWith, liftError', tshow, whenM) import Simplex.Messaging.Version import UnliftIO import UnliftIO.Directory @@ -228,13 +230,13 @@ createXFTPChunk :: createXFTPChunk c spKey file rcps auth_ = sendXFTPCommand c spKey "" (FNEW file rcps auth_) Nothing >>= \case (FRSndIds sId rIds, body) -> noFile body (sId, rIds) - (r, _) -> throwError . PCEUnexpectedResponse $ bshow r + (r, _) -> throwE $ unexpectedResponse r addXFTPRecipients :: XFTPClient -> C.APrivateAuthKey -> XFTPFileId -> NonEmpty C.APublicAuthKey -> ExceptT XFTPClientError IO (NonEmpty RecipientId) addXFTPRecipients c spKey fId rcps = sendXFTPCommand c spKey fId (FADD rcps) Nothing >>= \case (FRRcvIds rIds, body) -> noFile body rIds - (r, _) -> throwError . PCEUnexpectedResponse $ bshow r + (r, _) -> throwE $ unexpectedResponse r uploadXFTPChunk :: XFTPClient -> C.APrivateAuthKey -> XFTPFileId -> XFTPChunkSpec -> ExceptT XFTPClientError IO () uploadXFTPChunk c spKey fId chunkSpec = @@ -262,7 +264,7 @@ downloadXFTPChunk g c@XFTPClient {config} rpKey fId chunkSpec@XFTPRcvChunkSpec { receiveEncFile chunkPart cbState chunkSpec `catchError` \e -> whenM (doesFileExist filePath) (removeFile filePath) >> throwError e _ -> throwError $ PCEResponseError NO_FILE - (r, _) -> throwError . PCEUnexpectedResponse $ bshow r + (r, _) -> throwE $ unexpectedResponse r xftpReqTimeout :: XFTPClientConfig -> Maybe Word32 -> Int xftpReqTimeout cfg@XFTPClientConfig {xftpNetworkConfig = NetworkConfig {tcpTimeout}} chunkSize_ = @@ -286,12 +288,12 @@ pingXFTP c@XFTPClient {thParams} = do (r, _) <- sendXFTPTransmission c t Nothing case r of FRPong -> pure () - _ -> throwError $ PCEUnexpectedResponse $ bshow r + _ -> throwE $ unexpectedResponse r okResponse :: (FileResponse, HTTP2Body) -> ExceptT XFTPClientError IO () okResponse = \case (FROk, body) -> noFile body () - (r, _) -> throwError . PCEUnexpectedResponse $ bshow r + (r, _) -> throwE $ unexpectedResponse r -- TODO this currently does not check anything because response size is not set and bodyPart is always Just noFile :: HTTP2Body -> a -> ExceptT XFTPClientError IO a diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 7bcec443b..3096291de 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -159,7 +159,7 @@ import Simplex.Messaging.Agent.Store import Simplex.Messaging.Agent.Store.SQLite import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Client (ProtocolClient (..), ServerTransmission, TransmissionType (..)) +import Simplex.Messaging.Client (ProtocolClient (..), SMPClientError, ServerTransmission (..), ServerTransmissionBatch, temporaryClientError, unexpectedResponse) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile, CryptoFileArgs) import Simplex.Messaging.Crypto.Ratchet (PQEncryption, PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) @@ -170,7 +170,7 @@ import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode (NtfReg import Simplex.Messaging.Notifications.Server.Push.APNS (PNMessageData (..)) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (parse) -import Simplex.Messaging.Protocol (BrokerMsg, EntityId, ErrorType (AUTH), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI (..), SMPMsgMeta, SProtocolType (..), SndPublicAuthKey, SubscriptionMode (..), UserProtocol, VersionSMPC, XFTPServerWithAuth) +import Simplex.Messaging.Protocol (BrokerMsg, Cmd (..), EntityId, ErrorType (AUTH), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI (..), SMPMsgMeta, SParty (..), SProtocolType (..), SndPublicAuthKey, SubscriptionMode (..), UserProtocol, VersionSMPC, XFTPServerWithAuth) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM @@ -1998,14 +1998,10 @@ getSMPServer c userId = withUserServers c userId pickServer {-# INLINE getSMPServer #-} subscriber :: AgentClient -> AM' () -subscriber c@AgentClient {subQ, msgQ} = forever $ do +subscriber c@AgentClient {msgQ} = forever $ do t <- atomically $ readTBQueue msgQ agentOperationBracket c AORcvNetwork waitUntilActive $ - tryAgentError' (processSMPTransmission c t) >>= \case - Left e -> do - logError $ tshow e - atomically $ writeTBQueue subQ ("", "", APC SAEConn $ ERR e) - Right _ -> return () + processSMPTransmissions c t cleanupManager :: AgentClient -> AM' () cleanupManager c@AgentClient {subQ} = do @@ -2079,28 +2075,72 @@ cleanupManager c@AgentClient {subQ} = do data ACKd = ACKd | ACKPending --- | make sure to ACK or throw in each message processing branch --- it cannot be finally, unfortunately, as sometimes it needs to be ACK+DEL -processSMPTransmission :: AgentClient -> ServerTransmission SMPVersion ErrorType BrokerMsg -> AM () -processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, sessId, tType, rId, cmd) = do - (rq, SomeConn _ conn) <- withStore c (\db -> getRcvConn db srv rId) - processSMP rq conn $ toConnData conn +-- | Make sure to ACK or throw in each message processing branch +-- It cannot be finally, as sometimes it needs to be ACK+DEL, +-- and sometimes ACK has to be sent from the consumer. +processSMPTransmissions :: AgentClient -> ServerTransmissionBatch SMPVersion ErrorType BrokerMsg -> AM' () +processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) = do + upConnIds <- newTVarIO [] + forM_ ts $ \(entId, t) -> case t of + STEvent msgOrErr -> + withRcvConn entId $ \rq@RcvQueue {connId} conn -> case msgOrErr of + Right msg -> processSMP rq conn (toConnData conn) msg + Left e -> lift $ notifyErr connId e + STResponse (Cmd SRecipient cmd) respOrErr -> + withRcvConn entId $ \rq conn -> case cmd of + -- TODO process expired responses to ACK and DEL + SMP.SUB -> case respOrErr of + Right SMP.OK -> processSubOk rq upConnIds + Right msg@SMP.MSG {} -> do + processSubOk rq upConnIds + processSMP rq conn (toConnData conn) msg + Right r -> processSubErr rq $ unexpectedResponse r + Left e -> unless (temporaryClientError e) $ processSubErr rq e -- timeout/network was already reported + _ -> pure () + STResponse {} -> pure () -- TODO process expired responses to sent messages + STUnexpectedError e -> do + logServer "<--" c srv entId $ "error: " <> bshow e + notifyErr "" e + connIds <- readTVarIO upConnIds + unless (null connIds) $ notify' "" $ UP srv connIds where - processSMP :: forall c. RcvQueue -> Connection c -> ConnData -> AM () + withRcvConn :: SMP.RecipientId -> (forall c. RcvQueue -> Connection c -> AM ()) -> AM' () + withRcvConn rId a = do + tryAgentError' (withStore c $ \db -> getRcvConn db srv rId) >>= \case + Left e -> notify' "" (ERR e) + Right (rq@RcvQueue {connId}, SomeConn _ conn) -> + tryAgentError' (a rq conn) >>= \case + Left e -> notify' connId (ERR e) + Right () -> pure () + processSubOk :: RcvQueue -> TVar [ConnId] -> AM () + processSubOk rq@RcvQueue {connId} upConnIds = + atomically . whenM (isPendingSub connId) $ do + addSubscription c rq + modifyTVar' upConnIds (connId :) + processSubErr :: RcvQueue -> SMPClientError -> AM () + processSubErr rq@RcvQueue {connId} e = do + atomically . whenM (isPendingSub connId) $ failSubscription c rq e + lift $ notifyErr connId e + isPendingSub connId = (&&) <$> hasPendingSubscription c connId <*> activeClientSession c tSess sessId + notify' :: forall e m. (AEntityI e, MonadIO m) => ConnId -> ACommand 'Agent e -> m () + notify' connId msg = atomically $ writeTBQueue subQ ("", connId, APC (sAEntity @e) msg) + notifyErr :: ConnId -> SMPClientError -> AM' () + notifyErr connId = notify' connId . ERR . protocolClientError SMP (B.unpack $ strEncode srv) + processSMP :: forall c. RcvQueue -> Connection c -> ConnData -> BrokerMsg -> AM () processSMP - rq@RcvQueue {e2ePrivKey, e2eDhSecret, status} + rq@RcvQueue {rcvId = rId, e2ePrivKey, e2eDhSecret, status} conn - cData@ConnData {userId, connId, connAgentVersion, ratchetSyncState = rss} = - withConnLock c connId "processSMP" $ case cmd of - Right r@(SMP.MSG msg@SMP.RcvMessage {msgId = srvMsgId}) -> + cData@ConnData {userId, connId, connAgentVersion, ratchetSyncState = rss} + smpMsg = + withConnLock c connId "processSMP" $ case smpMsg of + SMP.MSG msg@SMP.RcvMessage {msgId = srvMsgId} -> void . handleNotifyAck $ do - isGET <- atomically $ hasGetLock c rq - unless isGET $ checkExpiredResponse r msg' <- decryptSMPMessage rq msg ack' <- handleNotifyAck $ case msg' of SMP.ClientRcvMsgBody {msgTs = srvTs, msgFlags, msgBody} -> processClientMsg srvTs msgFlags msgBody SMP.ClientRcvMsgQuota {} -> queueDrained >> ack - when isGET $ notify (MSGNTF $ SMP.rcvMessageMeta srvMsgId msg') + whenM (atomically $ hasGetLock c rq) $ + notify (MSGNTF $ SMP.rcvMessageMeta srvMsgId msg') pure ack' where queueDrained = case conn of @@ -2259,29 +2299,23 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, ackDel aId = enqueueCmd (ICAckDel rId srvMsgId aId) $> ACKd handleNotifyAck :: AM ACKd -> AM ACKd handleNotifyAck m = m `catchAgentError` \e -> notify (ERR e) >> ack - Right SMP.END -> - atomically (TM.lookup tSess smpClients $>>= (tryReadTMVar . sessionVar) >>= processEND) - >>= logServer "<--" c srv rId + SMP.END -> + atomically (TM.lookup tSess (smpClients c) $>>= (tryReadTMVar . sessionVar) >>= processEND) + >>= notifyEnd where processEND = \case Just (Right clnt) - | sessId == sessionId (thParams $ connectedClient clnt) -> do - removeSubscription c connId - notify' END - pure "END" - | otherwise -> ignored - _ -> ignored - ignored = pure "END from disconnected client - ignored" - Right (SMP.ERR e) -> notify $ ERR $ SMP (B.unpack $ strEncode srv) e - Right r@SMP.OK -> checkExpiredResponse r - Right r -> unexpected r - Left e -> notify $ ERR $ protocolClientError SMP (B.unpack $ strEncode srv) e + | sessId == sessionId (thParams $ connectedClient clnt) -> + removeSubscription c connId $> True + _ -> pure False + notifyEnd removed + | removed = notify END >> logServer "<--" c srv rId "END" + | otherwise = logServer "<--" c srv rId "END from disconnected client - ignored" + SMP.ERR e -> notify $ ERR $ SMP (B.unpack $ strEncode srv) e + r -> unexpected r where - notify :: forall e m. MonadIO m => AEntityI e => ACommand 'Agent e -> m () - notify = atomically . notify' - - notify' :: forall e. AEntityI e => ACommand 'Agent e -> STM () - notify' msg = writeTBQueue subQ ("", connId, APC (sAEntity @e) msg) + notify :: forall e m. (AEntityI e, MonadIO m) => ACommand 'Agent e -> m () + notify = notify' connId prohibited :: AM () prohibited = notify . ERR $ AGENT A_PROHIBITED @@ -2291,25 +2325,10 @@ processSMPTransmission c@AgentClient {smpClients, subQ} (tSess@(_, srv, _), _v, unexpected :: BrokerMsg -> AM () unexpected r = do - logServer "<--" c srv rId $ "unexpected: " <> bshow cmd + logServer "<--" c srv rId $ "unexpected: " <> bshow r -- TODO add extended information about transmission type once UNEXPECTED has string notify . ERR $ BROKER (B.unpack $ strEncode srv) $ UNEXPECTED (take 32 $ show r) - checkExpiredResponse :: BrokerMsg -> AM () - checkExpiredResponse r = case tType of - TTEvent -> pure () - TTUncorrelatedResponse -> unexpected r - TTExpiredResponse (SMP.Cmd _ cmd') -> case cmd' of - SMP.SUB -> do - added <- - atomically $ - ifM - ((&&) <$> hasPendingSubscription c connId <*> activeClientSession c tSess sessId) - (True <$ addSubscription c rq) - (pure False) - when added $ notify $ UP srv [connId] - _ -> pure () - decryptClientMessage :: C.DhSecretX25519 -> SMP.ClientMsgEnvelope -> AM (SMP.PrivHeader, AgentMsgEnvelope) decryptClientMessage e2eDh SMP.ClientMsgEnvelope {cmNonce, cmEncBody} = do clientMsg <- agentCbDecrypt e2eDh cmNonce cmEncBody diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index df6105766..64f8c172f 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -41,6 +41,7 @@ module Simplex.Messaging.Agent.Client getQueueMessage, decryptSMPMessage, addSubscription, + failSubscription, addNewQueueSubscription, getSubscriptions, sendConfirmation, @@ -268,7 +269,7 @@ data AgentClient = AgentClient active :: TVar Bool, rcvQ :: TBQueue (ATransmission 'Client), subQ :: TBQueue (ATransmission 'Agent), - msgQ :: TBQueue (ServerTransmission SMPVersion ErrorType BrokerMsg), + msgQ :: TBQueue (ServerTransmissionBatch SMPVersion ErrorType BrokerMsg), smpServers :: TMap UserId (NonEmpty SMPServerWithAuth), smpClients :: TMap SMPTransportSession SMPClientVar, -- smpProxiedRelays: @@ -1079,7 +1080,7 @@ protocolClientError :: (Show err, Encoding err) => (HostName -> err -> AgentErro protocolClientError protocolError_ host = \case PCEProtocolError e -> protocolError_ host e PCEResponseError e -> BROKER host $ RESPONSE $ B.unpack $ smpEncode e - PCEUnexpectedResponse r -> BROKER host $ UNEXPECTED $ take 32 $ show r + PCEUnexpectedResponse e -> BROKER host $ UNEXPECTED $ B.unpack e PCEResponseTimeout -> BROKER host TIMEOUT PCENetworkError -> BROKER host NETWORK PCEIncompatibleHost -> BROKER host HOST @@ -1269,9 +1270,8 @@ newRcvQueue c userId connId (ProtoServerWithAuth srv auth) vRange subMode = do processSubResult :: AgentClient -> RcvQueue -> Either SMPClientError () -> STM () processSubResult c rq@RcvQueue {connId} = \case Left e -> - unless (temporaryClientError e) $ do - RQ.deleteQueue rq (pendingSubs c) - TM.insert (RQ.qKey rq) e (removedSubs c) + unless (temporaryClientError e) $ + failSubscription c rq e Right () -> whenM (hasPendingSubscription c connId) $ addSubscription c rq @@ -1391,6 +1391,11 @@ addSubscription c rq@RcvQueue {connId} = do RQ.addQueue rq $ activeSubs c RQ.deleteQueue rq $ pendingSubs c +failSubscription :: AgentClient -> RcvQueue -> SMPClientError -> STM () +failSubscription c rq e = do + RQ.deleteQueue rq (pendingSubs c) + TM.insert (RQ.qKey rq) e (removedSubs c) + addPendingSubscription :: AgentClient -> RcvQueue -> STM () addPendingSubscription c rq@RcvQueue {connId} = do modifyTVar' (subscrConns c) $ S.insert connId diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 97a2867ca..2e05812cb 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -65,6 +65,7 @@ module Simplex.Messaging.Client ProtocolClientError (..), SMPClientError, ProxyClientError (..), + unexpectedResponse, ProtocolClientConfig (..), NetworkConfig (..), TransportSessionMode (..), @@ -80,8 +81,8 @@ module Simplex.Messaging.Client proxyUsername, temporaryClientError, smpProxyError, - ServerTransmission, - TransmissionType (..), + ServerTransmissionBatch, + ServerTransmission (..), ClientCommand, -- * For testing @@ -155,7 +156,7 @@ data PClient v err msg = PClient sentCommands :: TMap CorrId (Request err msg), sndQ :: TBQueue ByteString, rcvQ :: TBQueue (NonEmpty (SignedTransmission err msg)), - msgQ :: Maybe (TBQueue (ServerTransmission v err msg)) + msgQ :: Maybe (TBQueue (ServerTransmissionBatch v err msg)) } smpClientStub :: TVar ChaChaDRG -> ByteString -> VersionSMP -> Maybe (THandleAuth 'TClient) -> STM SMPClient @@ -206,10 +207,14 @@ type SMPClient = ProtocolClient SMPVersion ErrorType BrokerMsg -- | Type for client command data type ClientCommand msg = (Maybe C.APrivateAuthKey, EntityId, ProtoCommand msg) --- | Type synonym for transmission from some SPM server queue. -type ServerTransmission v err msg = (TransportSession msg, Version v, SessionId, TransmissionType msg, EntityId, Either (ProtocolClientError err) msg) +-- | Type synonym for transmission from SPM servers. +-- Batch response is presented as a single `ServerTransmissionBatch` tuple. +type ServerTransmissionBatch v err msg = (TransportSession msg, Version v, SessionId, NonEmpty (EntityId, ServerTransmission err msg)) -data TransmissionType msg = TTEvent | TTUncorrelatedResponse | TTExpiredResponse (ProtoCommand msg) +data ServerTransmission err msg + = STEvent (Either (ProtocolClientError err) msg) + | STResponse (ProtoCommand msg) (Either (ProtocolClientError err) msg) + | STUnexpectedError (ProtocolClientError err) data HostMode = -- | prefer (or require) onion hosts when connecting via SOCKS proxy @@ -396,7 +401,7 @@ type TransportSession msg = (UserId, ProtoServer msg, Maybe EntityId) -- -- A single queue can be used for multiple 'SMPClient' instances, -- as 'SMPServerTransmission' includes server information. -getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> TransportSession msg -> ProtocolClientConfig v -> Maybe (TBQueue (ServerTransmission v err msg)) -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) +getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> TransportSession msg -> ProtocolClientConfig v -> Maybe (TBQueue (ServerTransmissionBatch v err msg)) -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, clientALPN, serverVRange, agreeSecret} msgQ disconnected = do case chooseTransportHost networkConfig (host srv) of Right useHost -> @@ -498,38 +503,47 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize maxCnt = smpPingCount networkConfig process :: ProtocolClient v err msg -> IO () - process c = forever $ atomically (readTBQueue $ rcvQ $ client_ c) >>= mapM_ (processMsg c) + process c = forever $ atomically (readTBQueue $ rcvQ $ client_ c) >>= processMsgs c - processMsg :: ProtocolClient v err msg -> SignedTransmission err msg -> IO () - processMsg c@ProtocolClient {client_ = PClient {sentCommands}} (_, _, (corrId, entId, respOrErr)) - | not $ B.null $ bs corrId = + processMsgs :: ProtocolClient v err msg -> NonEmpty (SignedTransmission err msg) -> IO () + processMsgs c ts = do + tsVar <- newTVarIO [] + mapM_ (processMsg c tsVar) ts + ts' <- readTVarIO tsVar + forM_ msgQ $ \q -> + mapM_ (atomically . writeTBQueue q . serverTransmission c) (L.nonEmpty ts') + + processMsg :: ProtocolClient v err msg -> TVar [(EntityId, ServerTransmission err msg)] -> SignedTransmission err msg -> IO () + processMsg ProtocolClient {client_ = PClient {sentCommands}} tsVar (_, _, (corrId, entId, respOrErr)) + | B.null $ bs corrId = sendMsg $ STEvent clientResp + | otherwise = atomically (TM.lookup corrId sentCommands) >>= \case - Nothing -> sendMsg TTUncorrelatedResponse + Nothing -> sendMsg $ STUnexpectedError unexpected Just Request {entityId, command, pending, responseVar} -> do wasPending <- atomically $ do TM.delete corrId sentCommands ifM (swapTVar pending False) - (True <$ tryPutTMVar responseVar (response entityId)) + (True <$ tryPutTMVar responseVar (if entityId == entId then clientResp else Left unexpected)) (pure False) - unless wasPending $ sendMsg $ if entityId == entId then TTExpiredResponse command else TTUncorrelatedResponse - | otherwise = sendMsg TTEvent + unless wasPending $ sendMsg $ if entityId == entId then STResponse command clientResp else STUnexpectedError unexpected where - response entityId - | entityId == entId = clientResp - | otherwise = Left . PCEUnexpectedResponse $ bshow respOrErr + unexpected = unexpectedResponse respOrErr clientResp = case respOrErr of Left e -> Left $ PCEResponseError e Right r -> case protocolError r of Just e -> Left $ PCEProtocolError e _ -> Right r - sendMsg :: TransmissionType msg -> IO () - sendMsg tType = case msgQ of - Just q -> atomically $ writeTBQueue q $ serverTransmission c tType entId clientResp + sendMsg :: ServerTransmission err msg -> IO () + sendMsg t = case msgQ of + Just _ -> atomically $ modifyTVar' tsVar ((entId, t) :) Nothing -> case clientResp of - Left e -> logError $ "SMP client error: " <> tshow e - Right _ -> logWarn $ "SMP client unprocessed event" + Left e -> logError ("SMP client error: " <> tshow e) + Right _ -> logWarn ("SMP client unprocessed event") + +unexpectedResponse :: Show r => r -> ProtocolClientError err +unexpectedResponse = PCEUnexpectedResponse . B.pack . take 32 . show proxyUsername :: TransportSession msg -> ByteString proxyUsername (userId, _, entityId_) = C.sha256Hash $ bshow userId <> maybe "" (":" <>) entityId_ @@ -585,7 +599,7 @@ smpProxyError :: SMPClientError -> ErrorType smpProxyError = \case PCEProtocolError e -> PROXY $ PROTOCOL e PCEResponseError e -> PROXY $ BROKER $ RESPONSE $ B.unpack $ strEncode e - PCEUnexpectedResponse s -> PROXY $ BROKER $ UNEXPECTED $ B.unpack $ B.take 32 s + PCEUnexpectedResponse e -> PROXY $ BROKER $ UNEXPECTED $ B.unpack e PCEResponseTimeout -> PROXY $ BROKER TIMEOUT PCENetworkError -> PROXY $ BROKER NETWORK PCEIncompatibleHost -> PROXY $ BROKER HOST @@ -606,7 +620,7 @@ createSMPQueue :: createSMPQueue c (rKey, rpKey) dhKey auth subMode = sendSMPCommand c (Just rpKey) "" (NEW rKey dhKey auth subMode) >>= \case IDS qik -> pure qik - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r -- | Subscribe to the SMP queue. -- @@ -617,7 +631,7 @@ subscribeSMPQueue c@ProtocolClient {client_ = PClient {sendPings}} rpKey rId = d sendSMPCommand c (Just rpKey) rId SUB >>= \case OK -> pure () cmd@MSG {} -> liftIO $ writeSMPMessage c rId cmd - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r -- | Subscribe to multiple SMP queues batching commands if supported. subscribeSMPQueues :: SMPClient -> NonEmpty (RcvPrivateAuthKey, RecipientId) -> IO (NonEmpty (Either SMPClientError ())) @@ -637,15 +651,15 @@ processSUBResponse :: SMPClient -> Response ErrorType BrokerMsg -> IO (Either SM processSUBResponse c (Response rId r) = case r of Right OK -> pure $ Right () Right cmd@MSG {} -> writeSMPMessage c rId cmd $> Right () - Right r' -> pure . Left . PCEUnexpectedResponse $ bshow r' + Right r' -> pure . Left $ unexpectedResponse r' Left e -> pure $ Left e writeSMPMessage :: SMPClient -> RecipientId -> BrokerMsg -> IO () -writeSMPMessage c rId msg = atomically $ mapM_ (`writeTBQueue` serverTransmission c TTEvent rId (Right msg)) (msgQ $ client_ c) +writeSMPMessage c rId msg = atomically $ mapM_ (`writeTBQueue` serverTransmission c [(rId, STEvent (Right msg))]) (msgQ $ client_ c) -serverTransmission :: ProtocolClient v err msg -> TransmissionType msg -> RecipientId -> Either (ProtocolClientError err) msg -> ServerTransmission v err msg -serverTransmission ProtocolClient {thParams = THandleParams {thVersion, sessionId}, client_ = PClient {transportSession}} tType entityId msgOrErr = - (transportSession, thVersion, sessionId, tType, entityId, msgOrErr) +serverTransmission :: ProtocolClient v err msg -> NonEmpty (RecipientId, ServerTransmission err msg) -> ServerTransmissionBatch v err msg +serverTransmission ProtocolClient {thParams = THandleParams {thVersion, sessionId}, client_ = PClient {transportSession}} ts = + (transportSession, thVersion, sessionId, ts) -- | Get message from SMP queue. The server returns ERR PROHIBITED if a client uses SUB and GET via the same transport connection for the same queue -- @@ -655,7 +669,7 @@ getSMPMessage c rpKey rId = sendSMPCommand c (Just rpKey) rId GET >>= \case OK -> pure Nothing cmd@(MSG msg) -> liftIO (writeSMPMessage c rId cmd) $> Just msg - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r -- | Subscribe to the SMP queue notifications. -- @@ -683,7 +697,7 @@ enableSMPQueueNotifications :: SMPClient -> RcvPrivateAuthKey -> RecipientId -> enableSMPQueueNotifications c rpKey rId notifierKey rcvNtfPublicDhKey = sendSMPCommand c (Just rpKey) rId (NKEY notifierKey rcvNtfPublicDhKey) >>= \case NID nId rcvNtfSrvPublicDhKey -> pure (nId, rcvNtfSrvPublicDhKey) - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r -- | Enable notifications for the multiple queues for push notifications server. enableSMPQueuesNtfs :: SMPClient -> NonEmpty (RcvPrivateAuthKey, RecipientId, NtfPublicAuthKey, RcvNtfPublicDhKey) -> IO (NonEmpty (Either SMPClientError (NotifierId, RcvNtfPublicDhKey))) @@ -692,7 +706,7 @@ enableSMPQueuesNtfs c qs = L.map process <$> sendProtocolCommands c cs cs = L.map (\(rpKey, rId, notifierKey, rcvNtfPublicDhKey) -> (Just rpKey, rId, Cmd SRecipient $ NKEY notifierKey rcvNtfPublicDhKey)) qs process (Response _ r) = case r of Right (NID nId rcvNtfSrvPublicDhKey) -> Right (nId, rcvNtfSrvPublicDhKey) - Right r' -> Left . PCEUnexpectedResponse $ bshow r' + Right r' -> Left $ unexpectedResponse r' Left e -> Left e -- | Disable notifications for the queue for push notifications server. @@ -714,7 +728,7 @@ sendSMPMessage :: SMPClient -> Maybe SndPrivateAuthKey -> SenderId -> MsgFlags - sendSMPMessage c spKey sId flags msg = sendSMPCommand c spKey sId (SEND flags msg) >>= \case OK -> pure () - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r -- | Acknowledge message delivery (server deletes the message). -- @@ -724,7 +738,7 @@ ackSMPMessage c rpKey rId msgId = sendSMPCommand c (Just rpKey) rId (ACK msgId) >>= \case OK -> return () cmd@MSG {} -> liftIO $ writeSMPMessage c rId cmd - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r -- | Irreversibly suspend SMP queue. -- The existing messages from the queue will still be delivered. @@ -756,7 +770,7 @@ connectSMPProxiedRelay c@ProtocolClient {client_ = PClient {tcpConnectTimeout, t case supportedClientSMPRelayVRange `compatibleVersion` vr of Nothing -> throwE $ transportErr TEVersion Just (Compatible v) -> liftEitherWith (const $ transportErr $ TEHandshake IDENTITY) $ ProxiedRelay sId v <$> validateRelay chain key - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion where tOut = Just $ tcpConnectTimeout + tcpTimeout @@ -862,14 +876,14 @@ proxySMPMessage c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c (_auth, _signed, (_c, _e, cmd)) -> case cmd of Right OK -> pure $ Right () Right (ERR e) -> throwE $ PCEProtocolError e -- this is the error from the destination relay - Right e -> throwE $ PCEUnexpectedResponse $ B.take 32 $ bshow e + Right r' -> throwE $ unexpectedResponse r' Left e -> throwE $ PCEResponseError e _ -> throwE $ PCETransportError TEBadBlock ERR e -> pure . Left $ ProxyProtocolError e -- this will not happen, this error is returned via Left _ -> pure . Left $ ProxyUnexpectedResponse $ take 32 $ show r Left e -> case e of PCEProtocolError e' -> pure . Left $ ProxyProtocolError e' - PCEUnexpectedResponse r -> pure . Left $ ProxyUnexpectedResponse $ B.unpack r + PCEUnexpectedResponse e' -> pure . Left $ ProxyUnexpectedResponse $ B.unpack e' PCEResponseError e' -> pure . Left $ ProxyResponseError e' _ -> throwE e @@ -894,13 +908,13 @@ forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = r' <- liftEitherWith PCECryptoError $ C.cbDecryptNoPad sessSecret (C.reverseNonce nonce) efr FwdResponse {fwdCorrId = _, fwdResponse} <- liftEitherWith (const $ PCEResponseError BLOCK) $ smpDecode r' pure fwdResponse - r -> throwE . PCEUnexpectedResponse $ B.take 32 $ bshow r + r -> throwE $ unexpectedResponse r okSMPCommand :: PartyI p => Command p -> SMPClient -> C.APrivateAuthKey -> QueueId -> ExceptT SMPClientError IO () okSMPCommand cmd c pKey qId = sendSMPCommand c (Just pKey) qId cmd >>= \case OK -> return () - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r okSMPCommands :: PartyI p => Command p -> SMPClient -> NonEmpty (C.APrivateAuthKey, QueueId) -> IO (NonEmpty (Either SMPClientError ())) okSMPCommands cmd c qs = L.map process <$> sendProtocolCommands c cs @@ -909,7 +923,7 @@ okSMPCommands cmd c qs = L.map process <$> sendProtocolCommands c cs cs = L.map (\(pKey, qId) -> (Just pKey, qId, aCmd)) qs process (Response _ r) = case r of Right OK -> Right () - Right r' -> Left . PCEUnexpectedResponse $ bshow r' + Right r' -> Left $ unexpectedResponse r' Left e -> Left e -- | Send SMP command diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index e08f7b2c9..10b83157f 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -99,7 +99,7 @@ defaultSMPClientAgentConfig = data SMPClientAgent = SMPClientAgent { agentCfg :: SMPClientAgentConfig, active :: TVar Bool, - msgQ :: TBQueue (ServerTransmission SMPVersion ErrorType BrokerMsg), + msgQ :: TBQueue (ServerTransmissionBatch SMPVersion ErrorType BrokerMsg), agentQ :: TBQueue SMPClientAgentEvent, randomDrg :: TVar ChaChaDRG, smpClients :: TMap SMPServer SMPClientVar, diff --git a/src/Simplex/Messaging/Notifications/Client.hs b/src/Simplex/Messaging/Notifications/Client.hs index cc698b344..32d92faf3 100644 --- a/src/Simplex/Messaging/Notifications/Client.hs +++ b/src/Simplex/Messaging/Notifications/Client.hs @@ -12,7 +12,6 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol import Simplex.Messaging.Notifications.Transport (NTFVersion, supportedClientNTFVRange, supportedNTFHandshakes) import Simplex.Messaging.Protocol (ErrorType) -import Simplex.Messaging.Util (bshow) type NtfClient = ProtocolClient NTFVersion ErrorType NtfResponse @@ -25,7 +24,7 @@ ntfRegisterToken :: NtfClient -> C.APrivateAuthKey -> NewNtfEntity 'Token -> Exc ntfRegisterToken c pKey newTkn = sendNtfCommand c (Just pKey) "" (TNEW newTkn) >>= \case NRTknId tknId dhKey -> pure (tknId, dhKey) - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r ntfVerifyToken :: NtfClient -> C.APrivateAuthKey -> NtfTokenId -> NtfRegCode -> ExceptT NtfClientError IO () ntfVerifyToken c pKey tknId code = okNtfCommand (TVFY code) c pKey tknId @@ -34,7 +33,7 @@ ntfCheckToken :: NtfClient -> C.APrivateAuthKey -> NtfTokenId -> ExceptT NtfClie ntfCheckToken c pKey tknId = sendNtfCommand c (Just pKey) tknId TCHK >>= \case NRTkn stat -> pure stat - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r ntfReplaceToken :: NtfClient -> C.APrivateAuthKey -> NtfTokenId -> DeviceToken -> ExceptT NtfClientError IO () ntfReplaceToken c pKey tknId token = okNtfCommand (TRPL token) c pKey tknId @@ -49,13 +48,13 @@ ntfCreateSubscription :: NtfClient -> C.APrivateAuthKey -> NewNtfEntity 'Subscri ntfCreateSubscription c pKey newSub = sendNtfCommand c (Just pKey) "" (SNEW newSub) >>= \case NRSubId subId -> pure subId - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r ntfCheckSubscription :: NtfClient -> C.APrivateAuthKey -> NtfSubscriptionId -> ExceptT NtfClientError IO NtfSubStatus ntfCheckSubscription c pKey subId = sendNtfCommand c (Just pKey) subId SCHK >>= \case NRSub stat -> pure stat - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r ntfDeleteSubscription :: NtfClient -> C.APrivateAuthKey -> NtfSubscriptionId -> ExceptT NtfClientError IO () ntfDeleteSubscription = okNtfCommand SDEL @@ -68,4 +67,4 @@ okNtfCommand :: NtfEntityI e => NtfCommand e -> NtfClient -> C.APrivateAuthKey - okNtfCommand cmd c pKey entId = sendNtfCommand c (Just pKey) entId cmd >>= \case NROk -> return () - r -> throwE . PCEUnexpectedResponse $ bshow r + r -> throwE $ unexpectedResponse r diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index 5e4cf839e..892560660 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -31,7 +31,7 @@ import Data.Time.Clock (UTCTime (..), diffTimeToPicoseconds, getCurrentTime) import Data.Time.Clock.System (getSystemTime) import Data.Time.Format.ISO8601 (iso8601Show) import Network.Socket (ServiceName) -import Simplex.Messaging.Client (ProtocolClientError (..), SMPClientError) +import Simplex.Messaging.Client (ProtocolClientError (..), SMPClientError, ServerTransmission (..)) import Simplex.Messaging.Client.Agent import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -220,23 +220,27 @@ ntfSubscriber NtfSubscriber {smpSubscribers, newSubQ, smpAgent = ca@SMPClientAge receiveSMP :: M () receiveSMP = forever $ do - ((_, srv, _), _, _, _tType, ntfId, msgOrErr) <- atomically $ readTBQueue msgQ - let smpQueue = SMPQueueNtf srv ntfId - case msgOrErr of - Right (SMP.NMSG nmsgNonce encNMsgMeta) -> do - ntfTs <- liftIO getSystemTime - st <- asks store - NtfPushServer {pushQ} <- asks pushServer - stats <- asks serverStats - atomically $ updatePeriodStats (activeSubs stats) ntfId - atomically $ - findNtfSubscriptionToken st smpQueue - >>= mapM_ (\tkn -> writeTBQueue pushQ (tkn, PNMessage PNMessageData {smpQueue, ntfTs, nmsgNonce, encNMsgMeta})) - incNtfStat ntfReceived - Right SMP.END -> updateSubStatus smpQueue NSEnd - Right (SMP.ERR e) -> logError $ "SMP server error: " <> tshow e - Right _ -> logError $ "SMP server unexpected response" - Left e -> logError $ "SMP client error: " <> tshow e + ((_, srv, _), _, _, ts) <- atomically $ readTBQueue msgQ + forM ts $ \(ntfId, t) -> case t of + STUnexpectedError e -> logError $ "SMP client unexpected error: " <> tshow e -- uncorrelated response, should not happen + STResponse {} -> pure () -- it was already reported as timeout error + STEvent msgOrErr -> do + let smpQueue = SMPQueueNtf srv ntfId + case msgOrErr of + Right (SMP.NMSG nmsgNonce encNMsgMeta) -> do + ntfTs <- liftIO getSystemTime + st <- asks store + NtfPushServer {pushQ} <- asks pushServer + stats <- asks serverStats + atomically $ updatePeriodStats (activeSubs stats) ntfId + atomically $ + findNtfSubscriptionToken st smpQueue + >>= mapM_ (\tkn -> writeTBQueue pushQ (tkn, PNMessage PNMessageData {smpQueue, ntfTs, nmsgNonce, encNMsgMeta})) + incNtfStat ntfReceived + Right SMP.END -> updateSubStatus smpQueue NSEnd + Right (SMP.ERR e) -> logError $ "SMP server error: " <> tshow e + Right _ -> logError $ "SMP server unexpected response" + Left e -> logError $ "SMP client error: " <> tshow e receiveAgent = forever $ @@ -248,7 +252,6 @@ ntfSubscriber NtfSubscriber {smpSubscribers, newSubQ, smpAgent = ca@SMPClientAge forM_ subs $ \(_, ntfId) -> do let smpQueue = SMPQueueNtf srv ntfId updateSubStatus smpQueue NSInactive - CAResubscribed srv subs -> do forM_ subs $ \(_, ntfId) -> updateSubStatus (SMPQueueNtf srv ntfId) NSActive logSubStatus srv "resubscribed" $ length subs diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 467700784..1c458a062 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -120,7 +120,7 @@ deliverMessageViaProxy proxyServ relayServ alg msg msg' = do -- send via proxy to unsecured queue Right () <- proxySMPMessage pc sess Nothing sndId noMsgFlags msg -- receive 1 - (_tSess, _v, _sid, _isResp, _entId, Right (SMP.MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody})) <- atomically $ readTBQueue msgQ + (_tSess, _v, _sid, [(_entId, STEvent (Right (SMP.MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody})))]) <- atomically $ readTBQueue msgQ liftIO $ dec msgId encBody `shouldBe` Right msg ackSMPMessage rc rPriv rcvId msgId -- secure queue @@ -129,7 +129,7 @@ deliverMessageViaProxy proxyServ relayServ alg msg msg' = do -- send via proxy to secured queue Right () <- proxySMPMessage pc sess (Just sPriv) sndId noMsgFlags msg' -- receive 2 - (_tSess, _v, _sid, _isResp, _entId, Right (SMP.MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'})) <- atomically $ readTBQueue msgQ + (_tSess, _v, _sid, [(_entId, STEvent (Right (SMP.MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'})))]) <- atomically $ readTBQueue msgQ liftIO $ dec msgId' encBody' `shouldBe` Right msg' ackSMPMessage rc rPriv rcvId msgId' From 2e5433676eaa5de93ed1ea9726706b9633308477 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 May 2024 17:14:04 +0400 Subject: [PATCH 067/107] xftp: check proxy before downloading from unknown server (#1102) * xftp: check proxy before downloading from unknown server * corrections * remove import * tests * comment * tests * don't wrap into internal error * fix tests --- simplexmq.cabal | 1 + src/Simplex/FileTransfer/Agent.hs | 35 ++++++----- src/Simplex/FileTransfer/Transport.hs | 4 ++ src/Simplex/Messaging/Agent.hs | 7 ++- src/Simplex/Messaging/Agent/Client.hs | 7 +++ src/Simplex/Messaging/Agent/Store/SQLite.hs | 61 ++++++++++--------- .../Agent/Store/SQLite/Migrations.hs | 4 +- .../M20240417_rcv_files_approved_relays.hs | 18 ++++++ .../Store/SQLite/Migrations/agent_schema.sql | 1 + src/Simplex/Messaging/Client.hs | 1 + tests/AgentTests/SQLiteTests.hs | 10 +-- tests/XFTPAgent.hs | 29 +++++---- 12 files changed, 110 insertions(+), 68 deletions(-) create mode 100644 src/Simplex/Messaging/Agent/Store/SQLite/Migrations/M20240417_rcv_files_approved_relays.hs diff --git a/simplexmq.cabal b/simplexmq.cabal index d077deaae..7d2759b46 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -105,6 +105,7 @@ library Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240124_file_redirect Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240223_connections_wait_delivery Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240225_ratchet_kem + Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240417_rcv_files_approved_relays Simplex.Messaging.Agent.TRcvQueues Simplex.Messaging.Client Simplex.Messaging.Client.Agent diff --git a/src/Simplex/FileTransfer/Agent.hs b/src/Simplex/FileTransfer/Agent.hs index a5ba40f4a..c8030b206 100644 --- a/src/Simplex/FileTransfer/Agent.hs +++ b/src/Simplex/FileTransfer/Agent.hs @@ -112,8 +112,8 @@ closeXFTPAgent a = do where stopWorkers workers = atomically (swapTVar workers M.empty) >>= mapM_ (liftIO . cancelWorker) -xftpReceiveFile' :: AgentClient -> UserId -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> AM RcvFileId -xftpReceiveFile' c userId (ValidFileDescription fd@FileDescription {chunks, redirect}) cfArgs = do +xftpReceiveFile' :: AgentClient -> UserId -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> Bool -> AM RcvFileId +xftpReceiveFile' c userId (ValidFileDescription fd@FileDescription {chunks, redirect}) cfArgs approvedRelays = do g <- asks random prefixPath <- lift $ getPrefixPath "rcv.xftp" createDirectory prefixPath @@ -124,7 +124,7 @@ xftpReceiveFile' c userId (ValidFileDescription fd@FileDescription {chunks, redi lift $ createEmptyFile =<< toFSFilePath relSavePath let saveFile = CryptoFile relSavePath cfArgs fId <- case redirect of - Nothing -> withStore c $ \db -> createRcvFile db g userId fd relPrefixPath relTmpPath saveFile + Nothing -> withStore c $ \db -> createRcvFile db g userId fd relPrefixPath relTmpPath saveFile approvedRelays Just _ -> do -- prepare description paths let relTmpPathRedirect = relPrefixPath "xftp.redirect-encrypted" @@ -134,7 +134,7 @@ xftpReceiveFile' c userId (ValidFileDescription fd@FileDescription {chunks, redi cfArgsRedirect <- atomically $ CF.randomArgs g let saveFileRedirect = CryptoFile relSavePathRedirect $ Just cfArgsRedirect -- create download tasks - withStore c $ \db -> createRcvFileRedirect db g userId fd relPrefixPath relTmpPathRedirect saveFileRedirect relTmpPath saveFile + withStore c $ \db -> createRcvFileRedirect db g userId fd relPrefixPath relTmpPathRedirect saveFileRedirect relTmpPath saveFile approvedRelays forM_ chunks (downloadChunk c) pure fId @@ -176,12 +176,12 @@ runXFTPRcvWorker c srv Worker {doWork} = do runXFTPOperation :: AgentConfig -> AM () runXFTPOperation AgentConfig {rcvFilesTTL, reconnectInterval = ri, xftpNotifyErrsOnRetry = notifyOnRetry, xftpConsecutiveRetries} = withWork c doWork (\db -> getNextRcvChunkToDownload db srv rcvFilesTTL) $ \case - RcvFileChunk {rcvFileId, rcvFileEntityId, fileTmpPath, replicas = []} -> rcvWorkerInternalError c rcvFileId rcvFileEntityId (Just fileTmpPath) "chunk has no replicas" - fc@RcvFileChunk {userId, rcvFileId, rcvFileEntityId, digest, fileTmpPath, replicas = replica@RcvFileChunkReplica {rcvChunkReplicaId, server, delay} : _} -> do + (RcvFileChunk {rcvFileId, rcvFileEntityId, fileTmpPath, replicas = []}, _) -> rcvWorkerInternalError c rcvFileId rcvFileEntityId (Just fileTmpPath) (INTERNAL "chunk has no replicas") + (fc@RcvFileChunk {userId, rcvFileId, rcvFileEntityId, digest, fileTmpPath, replicas = replica@RcvFileChunkReplica {rcvChunkReplicaId, server, delay} : _}, approvedRelays) -> do let ri' = maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) delay withRetryIntervalLimit xftpConsecutiveRetries ri' $ \delay' loop -> do liftIO $ waitForUserNetwork c - downloadFileChunk fc replica + downloadFileChunk fc replica approvedRelays `catchAgentError` \e -> retryOnError "XFTP rcv worker" (retryLoop loop e delay') (retryDone e) e where retryLoop loop e replicaDelay = do @@ -191,9 +191,10 @@ runXFTPRcvWorker c srv Worker {doWork} = do withStore' c $ \db -> updateRcvChunkReplicaDelay db rcvChunkReplicaId replicaDelay atomically $ assertAgentForeground c loop - retryDone e = rcvWorkerInternalError c rcvFileId rcvFileEntityId (Just fileTmpPath) (show e) - downloadFileChunk :: RcvFileChunk -> RcvFileChunkReplica -> AM () - downloadFileChunk RcvFileChunk {userId, rcvFileId, rcvFileEntityId, rcvChunkId, chunkNo, chunkSize, digest, fileTmpPath} replica = do + retryDone = rcvWorkerInternalError c rcvFileId rcvFileEntityId (Just fileTmpPath) + downloadFileChunk :: RcvFileChunk -> RcvFileChunkReplica -> Bool -> AM () + downloadFileChunk RcvFileChunk {userId, rcvFileId, rcvFileEntityId, rcvChunkId, chunkNo, chunkSize, digest, fileTmpPath} replica approvedRelays = do + unlessM ((approvedRelays ||) <$> ipAddressProtected') $ throwError $ XFTP "" XFTP.NOT_APPROVED fsFileTmpPath <- lift $ toFSFilePath fileTmpPath chunkPath <- uniqueCombine fsFileTmpPath $ show chunkNo let chunkSpec = XFTPRcvChunkSpec chunkPath (unFileSize chunkSize) (unFileDigest digest) @@ -214,6 +215,10 @@ runXFTPRcvWorker c srv Worker {doWork} = do when complete . lift . void $ getXFTPRcvWorker True c Nothing where + ipAddressProtected' :: AM Bool + ipAddressProtected' = do + cfg <- liftIO $ getNetworkConfig' c + pure $ ipAddressProtected cfg srv receivedSize :: [RcvFileChunk] -> Int64 receivedSize = foldl' (\sz ch -> sz + receivedChunkSize ch) 0 receivedChunkSize ch@RcvFileChunk {chunkSize = s} @@ -234,11 +239,11 @@ retryOnError name loop done e = do then loop else done -rcvWorkerInternalError :: AgentClient -> DBRcvFileId -> RcvFileId -> Maybe FilePath -> String -> AM () -rcvWorkerInternalError c rcvFileId rcvFileEntityId tmpPath internalErrStr = do +rcvWorkerInternalError :: AgentClient -> DBRcvFileId -> RcvFileId -> Maybe FilePath -> AgentErrorType -> AM () +rcvWorkerInternalError c rcvFileId rcvFileEntityId tmpPath err = do lift $ forM_ tmpPath (removePath <=< toFSFilePath) - withStore' c $ \db -> updateRcvFileError db rcvFileId internalErrStr - notify c rcvFileEntityId $ RFERR $ INTERNAL internalErrStr + withStore' c $ \db -> updateRcvFileError db rcvFileId (show err) + notify c rcvFileEntityId $ RFERR err runXFTPRcvLocalWorker :: AgentClient -> Worker -> AM () runXFTPRcvLocalWorker c Worker {doWork} = do @@ -252,7 +257,7 @@ runXFTPRcvLocalWorker c Worker {doWork} = do runXFTPOperation AgentConfig {rcvFilesTTL} = withWork c doWork (`getNextRcvFileToDecrypt` rcvFilesTTL) $ \f@RcvFile {rcvFileId, rcvFileEntityId, tmpPath} -> - decryptFile f `catchAgentError` (rcvWorkerInternalError c rcvFileId rcvFileEntityId tmpPath . show) + decryptFile f `catchAgentError` rcvWorkerInternalError c rcvFileId rcvFileEntityId tmpPath decryptFile :: RcvFile -> AM () decryptFile RcvFile {rcvFileId, rcvFileEntityId, size, digest, key, nonce, tmpPath, saveFile, status, chunks, redirect} = do let CryptoFile savePath cfArgs = saveFile diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index 244e00972..2f0a5de4f 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -217,6 +217,8 @@ data XFTPErrorType TIMEOUT | -- | bad redirect data REDIRECT {redirectError :: String} + | -- | cannot proceed with download from not approved relays without proxy + NOT_APPROVED | -- | internal server error INTERNAL | -- | used internally, never returned by the server (to be removed) @@ -249,6 +251,7 @@ instance Encoding XFTPErrorType where FILE_IO -> "FILE_IO" TIMEOUT -> "TIMEOUT" REDIRECT err -> "REDIRECT " <> smpEncode err + NOT_APPROVED -> "NOT_APPROVED" INTERNAL -> "INTERNAL" DUPLICATE_ -> "DUPLICATE_" @@ -268,6 +271,7 @@ instance Encoding XFTPErrorType where "FILE_IO" -> pure FILE_IO "TIMEOUT" -> pure TIMEOUT "REDIRECT" -> REDIRECT <$> _smpP + "NOT_APPROVED" -> pure NOT_APPROVED "INTERNAL" -> pure INTERNAL "DUPLICATE_" -> pure DUPLICATE_ _ -> fail "bad error type" diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 3096291de..52d9b0086 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -83,6 +83,7 @@ module Simplex.Messaging.Agent setNtfServers, setNetworkConfig, getNetworkConfig, + getNetworkConfig', setUserNetworkInfo, reconnectAllServers, registerNtfToken, @@ -426,7 +427,7 @@ setNetworkConfig c@AgentClient {useNetworkConfig} cfg' = do -- returns fast network config getNetworkConfig :: AgentClient -> IO NetworkConfig -getNetworkConfig = fmap snd . readTVarIO . useNetworkConfig +getNetworkConfig = getNetworkConfig' {-# INLINE getNetworkConfig #-} setUserNetworkInfo :: AgentClient -> UserNetworkInfo -> IO () @@ -483,8 +484,8 @@ xftpStartWorkers c = withAgentEnv c . startXFTPWorkers c {-# INLINE xftpStartWorkers #-} -- | Receive XFTP file -xftpReceiveFile :: AgentClient -> UserId -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> AE RcvFileId -xftpReceiveFile c = withAgentEnv c .:. xftpReceiveFile' c +xftpReceiveFile :: AgentClient -> UserId -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> Bool -> AE RcvFileId +xftpReceiveFile c = withAgentEnv c .:: xftpReceiveFile' c {-# INLINE xftpReceiveFile #-} -- | Delete XFTP rcv file (deletes work files from file system and db records) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 64f8c172f..addd889a8 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -28,6 +28,7 @@ module Simplex.Messaging.Agent.Client withConnLocks, withInvLock, withLockMap, + ipAddressProtected, closeAgentClient, closeProtocolServerClients, reconnectServerClients, @@ -109,6 +110,7 @@ module Simplex.Messaging.Agent.Client waitUntilActive, UserNetworkInfo (..), UserNetworkType (..), + getNetworkConfig', waitForUserNetwork, isNetworkOnline, isOnline, @@ -854,6 +856,11 @@ getNetworkConfig c = do UNNone -> slowCfg _ -> fastCfg +-- returns fast network config +getNetworkConfig' :: AgentClient -> IO NetworkConfig +getNetworkConfig' = fmap snd . readTVarIO . useNetworkConfig +{-# INLINE getNetworkConfig' #-} + waitForUserNetwork :: AgentClient -> IO () waitForUserNetwork c = unlessM (atomically $ isNetworkOnline c) $ do diff --git a/src/Simplex/Messaging/Agent/Store/SQLite.hs b/src/Simplex/Messaging/Agent/Store/SQLite.hs index e47d2a15c..04dd826a6 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Store/SQLite.hs @@ -2275,20 +2275,20 @@ getXFTPServerId_ db ProtocolServer {host, port, keyHash} = do firstRow fromOnly SEXFTPServerNotFound $ DB.query db "SELECT xftp_server_id FROM xftp_servers WHERE xftp_host = ? AND xftp_port = ? AND xftp_key_hash = ?" (host, port, keyHash) -createRcvFile :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription 'FRecipient -> FilePath -> FilePath -> CryptoFile -> IO (Either StoreError RcvFileId) -createRcvFile db gVar userId fd@FileDescription {chunks} prefixPath tmpPath file = runExceptT $ do - (rcvFileEntityId, rcvFileId) <- ExceptT $ insertRcvFile db gVar userId fd prefixPath tmpPath file Nothing Nothing +createRcvFile :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription 'FRecipient -> FilePath -> FilePath -> CryptoFile -> Bool -> IO (Either StoreError RcvFileId) +createRcvFile db gVar userId fd@FileDescription {chunks} prefixPath tmpPath file approvedRelays = runExceptT $ do + (rcvFileEntityId, rcvFileId) <- ExceptT $ insertRcvFile db gVar userId fd prefixPath tmpPath file Nothing Nothing approvedRelays liftIO $ forM_ chunks $ \fc@FileChunk {replicas} -> do chunkId <- insertRcvFileChunk db fc rcvFileId forM_ (zip [1 ..] replicas) $ \(rno, replica) -> insertRcvFileChunkReplica db rno replica chunkId pure rcvFileEntityId -createRcvFileRedirect :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription FRecipient -> FilePath -> FilePath -> CryptoFile -> FilePath -> CryptoFile -> IO (Either StoreError RcvFileId) -createRcvFileRedirect _ _ _ FileDescription {redirect = Nothing} _ _ _ _ _ = pure $ Left $ SEInternal "createRcvFileRedirect called without redirect" -createRcvFileRedirect db gVar userId redirectFd@FileDescription {chunks = redirectChunks, redirect = Just RedirectFileInfo {size, digest}} prefixPath redirectPath redirectFile dstPath dstFile = runExceptT $ do - (dstEntityId, dstId) <- ExceptT $ insertRcvFile db gVar userId dummyDst prefixPath dstPath dstFile Nothing Nothing - (_, redirectId) <- ExceptT $ insertRcvFile db gVar userId redirectFd prefixPath redirectPath redirectFile (Just dstId) (Just dstEntityId) +createRcvFileRedirect :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription FRecipient -> FilePath -> FilePath -> CryptoFile -> FilePath -> CryptoFile -> Bool -> IO (Either StoreError RcvFileId) +createRcvFileRedirect _ _ _ FileDescription {redirect = Nothing} _ _ _ _ _ _ = pure $ Left $ SEInternal "createRcvFileRedirect called without redirect" +createRcvFileRedirect db gVar userId redirectFd@FileDescription {chunks = redirectChunks, redirect = Just RedirectFileInfo {size, digest}} prefixPath redirectPath redirectFile dstPath dstFile approvedRelays = runExceptT $ do + (dstEntityId, dstId) <- ExceptT $ insertRcvFile db gVar userId dummyDst prefixPath dstPath dstFile Nothing Nothing approvedRelays + (_, redirectId) <- ExceptT $ insertRcvFile db gVar userId redirectFd prefixPath redirectPath redirectFile (Just dstId) (Just dstEntityId) approvedRelays liftIO $ forM_ redirectChunks $ \fc@FileChunk {replicas} -> do chunkId <- insertRcvFileChunk db fc redirectId @@ -2308,8 +2308,8 @@ createRcvFileRedirect db gVar userId redirectFd@FileDescription {chunks = redire chunks = [] } -insertRcvFile :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription 'FRecipient -> FilePath -> FilePath -> CryptoFile -> Maybe DBRcvFileId -> Maybe RcvFileId -> IO (Either StoreError (RcvFileId, DBRcvFileId)) -insertRcvFile db gVar userId FileDescription {size, digest, key, nonce, chunkSize, redirect} prefixPath tmpPath (CryptoFile savePath cfArgs) redirectId_ redirectEntityId_ = runExceptT $ do +insertRcvFile :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription 'FRecipient -> FilePath -> FilePath -> CryptoFile -> Maybe DBRcvFileId -> Maybe RcvFileId -> Bool -> IO (Either StoreError (RcvFileId, DBRcvFileId)) +insertRcvFile db gVar userId FileDescription {size, digest, key, nonce, chunkSize, redirect} prefixPath tmpPath (CryptoFile savePath cfArgs) redirectId_ redirectEntityId_ approvedRelays = runExceptT $ do let (redirectDigest_, redirectSize_) = case redirect of Just RedirectFileInfo {digest = d, size = s} -> (Just d, Just s) Nothing -> (Nothing, Nothing) @@ -2317,8 +2317,8 @@ insertRcvFile db gVar userId FileDescription {size, digest, key, nonce, chunkSiz createWithRandomId gVar $ \rcvFileEntityId -> DB.execute db - "INSERT INTO rcv_files (rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, redirect_id, redirect_entity_id, redirect_digest, redirect_size) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((rcvFileEntityId, userId, size, digest, key, nonce, chunkSize, prefixPath, tmpPath) :. (savePath, fileKey <$> cfArgs, fileNonce <$> cfArgs, RFSReceiving, redirectId_, redirectEntityId_, redirectDigest_, redirectSize_)) + "INSERT INTO rcv_files (rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, redirect_id, redirect_entity_id, redirect_digest, redirect_size, approved_relays) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((rcvFileEntityId, userId, size, digest, key, nonce, chunkSize, prefixPath, tmpPath) :. (savePath, fileKey <$> cfArgs, fileNonce <$> cfArgs, RFSReceiving, redirectId_, redirectEntityId_, redirectDigest_, redirectSize_, approvedRelays)) rcvFileId <- liftIO $ insertedRowId db pure (rcvFileEntityId, rcvFileId) @@ -2468,7 +2468,7 @@ deleteRcvFile' :: DB.Connection -> DBRcvFileId -> IO () deleteRcvFile' db rcvFileId = DB.execute db "DELETE FROM rcv_files WHERE rcv_file_id = ?" (Only rcvFileId) -getNextRcvChunkToDownload :: DB.Connection -> XFTPServer -> NominalDiffTime -> IO (Either StoreError (Maybe RcvFileChunk)) +getNextRcvChunkToDownload :: DB.Connection -> XFTPServer -> NominalDiffTime -> IO (Either StoreError (Maybe (RcvFileChunk, Bool))) getNextRcvChunkToDownload db server@ProtocolServer {host, port, keyHash} ttl = do getWorkItem "rcv_file_download" getReplicaId getChunkData (markRcvFileFailed db . snd) where @@ -2492,7 +2492,7 @@ getNextRcvChunkToDownload db server@ProtocolServer {host, port, keyHash} ttl = d LIMIT 1 |] (host, port, keyHash, RFSReceiving, cutoffTs) - getChunkData :: (Int64, DBRcvFileId) -> IO (Either StoreError RcvFileChunk) + getChunkData :: (Int64, DBRcvFileId) -> IO (Either StoreError (RcvFileChunk, Bool)) getChunkData (rcvFileChunkReplicaId, _fileId) = firstRow toChunk SEFileNotFound $ DB.query @@ -2500,7 +2500,8 @@ getNextRcvChunkToDownload db server@ProtocolServer {host, port, keyHash} ttl = d [sql| SELECT f.rcv_file_id, f.rcv_file_entity_id, f.user_id, c.rcv_file_chunk_id, c.chunk_no, c.chunk_size, c.digest, f.tmp_path, c.tmp_path, - r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + f.approved_relays FROM rcv_file_chunk_replicas r JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id @@ -2509,20 +2510,22 @@ getNextRcvChunkToDownload db server@ProtocolServer {host, port, keyHash} ttl = d |] (Only rcvFileChunkReplicaId) where - toChunk :: ((DBRcvFileId, RcvFileId, UserId, Int64, Int, FileSize Word32, FileDigest, FilePath, Maybe FilePath) :. (Int64, ChunkReplicaId, C.APrivateAuthKey, Bool, Maybe Int64, Int)) -> RcvFileChunk - toChunk ((rcvFileId, rcvFileEntityId, userId, rcvChunkId, chunkNo, chunkSize, digest, fileTmpPath, chunkTmpPath) :. (rcvChunkReplicaId, replicaId, replicaKey, received, delay, retries)) = - RcvFileChunk - { rcvFileId, - rcvFileEntityId, - userId, - rcvChunkId, - chunkNo, - chunkSize, - digest, - fileTmpPath, - chunkTmpPath, - replicas = [RcvFileChunkReplica {rcvChunkReplicaId, server, replicaId, replicaKey, received, delay, retries}] - } + toChunk :: ((DBRcvFileId, RcvFileId, UserId, Int64, Int, FileSize Word32, FileDigest, FilePath, Maybe FilePath) :. (Int64, ChunkReplicaId, C.APrivateAuthKey, Bool, Maybe Int64, Int) :. Only Bool) -> (RcvFileChunk, Bool) + toChunk ((rcvFileId, rcvFileEntityId, userId, rcvChunkId, chunkNo, chunkSize, digest, fileTmpPath, chunkTmpPath) :. (rcvChunkReplicaId, replicaId, replicaKey, received, delay, retries) :. (Only approvedRelays)) = + ( RcvFileChunk + { rcvFileId, + rcvFileEntityId, + userId, + rcvChunkId, + chunkNo, + chunkSize, + digest, + fileTmpPath, + chunkTmpPath, + replicas = [RcvFileChunkReplica {rcvChunkReplicaId, server, replicaId, replicaKey, received, delay, retries}] + }, + approvedRelays + ) getNextRcvFileToDecrypt :: DB.Connection -> NominalDiffTime -> IO (Either StoreError (Maybe RcvFile)) getNextRcvFileToDecrypt db ttl = diff --git a/src/Simplex/Messaging/Agent/Store/SQLite/Migrations.hs b/src/Simplex/Messaging/Agent/Store/SQLite/Migrations.hs index 344a3f9ce..5a5ed5b5b 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite/Migrations.hs +++ b/src/Simplex/Messaging/Agent/Store/SQLite/Migrations.hs @@ -71,6 +71,7 @@ import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240121_message_deliver import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240124_file_redirect import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240223_connections_wait_delivery import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240225_ratchet_kem +import Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240417_rcv_files_approved_relays import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Transport.Client (TransportHost) @@ -110,7 +111,8 @@ schemaMigrations = ("m20240121_message_delivery_indexes", m20240121_message_delivery_indexes, Just down_m20240121_message_delivery_indexes), ("m20240124_file_redirect", m20240124_file_redirect, Just down_m20240124_file_redirect), ("m20240223_connections_wait_delivery", m20240223_connections_wait_delivery, Just down_m20240223_connections_wait_delivery), - ("m20240225_ratchet_kem", m20240225_ratchet_kem, Just down_m20240225_ratchet_kem) + ("m20240225_ratchet_kem", m20240225_ratchet_kem, Just down_m20240225_ratchet_kem), + ("m20240417_rcv_files_approved_relays", m20240417_rcv_files_approved_relays, Just down_m20240417_rcv_files_approved_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/M20240417_rcv_files_approved_relays.hs b/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/M20240417_rcv_files_approved_relays.hs new file mode 100644 index 000000000..9eb10c27a --- /dev/null +++ b/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/M20240417_rcv_files_approved_relays.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Messaging.Agent.Store.SQLite.Migrations.M20240417_rcv_files_approved_relays where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240417_rcv_files_approved_relays :: Query +m20240417_rcv_files_approved_relays = + [sql| +ALTER TABLE rcv_files ADD COLUMN approved_relays INTEGER NOT NULL DEFAULT 0; +|] + +down_m20240417_rcv_files_approved_relays :: Query +down_m20240417_rcv_files_approved_relays = + [sql| +ALTER TABLE rcv_files DROP COLUMN approved_relays; +|] diff --git a/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/agent_schema.sql b/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/agent_schema.sql index 0818be904..caf94418a 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/agent_schema.sql +++ b/src/Simplex/Messaging/Agent/Store/SQLite/Migrations/agent_schema.sql @@ -287,6 +287,7 @@ CREATE TABLE rcv_files( redirect_entity_id BLOB, redirect_size INTEGER, redirect_digest BLOB, + approved_relays INTEGER NOT NULL DEFAULT 0, UNIQUE(rcv_file_entity_id) ); CREATE TABLE rcv_file_chunks( diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 2e05812cb..34aa42462 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -84,6 +84,7 @@ module Simplex.Messaging.Client ServerTransmissionBatch, ServerTransmission (..), ClientCommand, + HostMode (..), -- * For testing PCTransmission, diff --git a/tests/AgentTests/SQLiteTests.hs b/tests/AgentTests/SQLiteTests.hs index 436dd0eca..63466b9d7 100644 --- a/tests/AgentTests/SQLiteTests.hs +++ b/tests/AgentTests/SQLiteTests.hs @@ -709,15 +709,15 @@ testGetNextRcvChunkToDownload st = do withTransaction st $ \db -> do Right Nothing <- getNextRcvChunkToDownload db xftpServer1 86400 - Right _ <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) + Right _ <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) True DB.execute_ db "UPDATE rcv_file_chunk_replicas SET replica_key = cast('bad' as blob) WHERE rcv_file_chunk_replica_id = 1" - Right fId2 <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) + Right fId2 <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) True Left e <- getNextRcvChunkToDownload db xftpServer1 86400 show e `shouldContain` "ConversionFailed" DB.query_ db "SELECT rcv_file_id FROM rcv_files WHERE failed = 1" `shouldReturn` [Only (1 :: Int)] - Right (Just RcvFileChunk {rcvFileEntityId}) <- getNextRcvChunkToDownload db xftpServer1 86400 + Right (Just (RcvFileChunk {rcvFileEntityId}, _)) <- getNextRcvChunkToDownload db xftpServer1 86400 rcvFileEntityId `shouldBe` fId2 testGetNextRcvFileToDecrypt :: SQLiteStore -> Expectation @@ -726,10 +726,10 @@ testGetNextRcvFileToDecrypt st = do withTransaction st $ \db -> do Right Nothing <- getNextRcvFileToDecrypt db 86400 - Right _ <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) + Right _ <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) True DB.execute_ db "UPDATE rcv_files SET status = 'received' WHERE rcv_file_id = 1" DB.execute_ db "UPDATE rcv_file_chunk_replicas SET replica_key = cast('bad' as blob) WHERE rcv_file_chunk_replica_id = 1" - Right fId2 <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) + Right fId2 <- createRcvFile db g 1 rcvFileDescr1 "filepath" "filepath" (CryptoFile "filepath" Nothing) True DB.execute_ db "UPDATE rcv_files SET status = 'received' WHERE rcv_file_id = 2" Left e <- getNextRcvFileToDecrypt db 86400 diff --git a/tests/XFTPAgent.hs b/tests/XFTPAgent.hs index 0610bf48d..8f42ec16a 100644 --- a/tests/XFTPAgent.hs +++ b/tests/XFTPAgent.hs @@ -179,7 +179,7 @@ testXFTPAgentSendReceiveRedirect = withXFTPServer $ do withAgent 2 agentCfg initAgentServers testDB2 $ \rcp -> do FileDescriptionURI {description} <- either fail pure $ strDecode uri - rcvFileId <- runRight $ xftpReceiveFile rcp 1 description Nothing + rcvFileId <- runRight $ xftpReceiveFile rcp 1 description Nothing True rfGet rcp `shouldReturn` ("", rcvFileId, RFPROG 65536 totalSize) -- extra RFPROG before switching to real file rfGet rcp `shouldReturn` ("", rcvFileId, RFPROG 4194304 totalSize) rfGet rcp `shouldReturn` ("", rcvFileId, RFPROG 8388608 totalSize) @@ -223,7 +223,7 @@ testXFTPAgentSendReceiveNoRedirect = withXFTPServer $ do FileDescriptionURI {description} <- either fail pure $ strDecode uri let ValidFileDescription FileDescription {redirect} = description redirect `shouldBe` Nothing - rcvFileId <- runRight $ xftpReceiveFile rcp 1 description Nothing + rcvFileId <- runRight $ xftpReceiveFile rcp 1 description Nothing True -- NO extra "RFPROG 65k 65k" before switching to real file rfGet rcp `shouldReturn` ("", rcvFileId, RFPROG 4194304 totalSize) rfGet rcp `shouldReturn` ("", rcvFileId, RFPROG 5242880 totalSize) @@ -311,7 +311,7 @@ testReceive' rcp rfd originalFilePath = testReceiveCF' rcp rfd Nothing originalF testReceiveCF' :: HasCallStack => AgentClient -> ValidFileDescription 'FRecipient -> Maybe CryptoFileArgs -> FilePath -> Int64 -> ExceptT AgentErrorType IO RcvFileId testReceiveCF' rcp rfd cfArgs originalFilePath size = do - rfId <- xftpReceiveFile rcp 1 rfd cfArgs + rfId <- xftpReceiveFile rcp 1 rfd cfArgs True rfProgress rcp size ("", rfId', RFDONE path) <- rfGet rcp liftIO $ do @@ -336,7 +336,7 @@ testXFTPAgentReceiveRestore = do -- receive file - should not succeed with server down rfId <- withAgent 2 agentCfg initAgentServers testDB2 $ \rcp -> runRight $ do xftpStartWorkers rcp (Just recipientFiles) - rfId <- xftpReceiveFile rcp 1 rfd Nothing + rfId <- xftpReceiveFile rcp 1 rfd Nothing True liftIO $ timeout 300000 (get rcp) `shouldReturn` Nothing -- wait for worker attempt pure rfId @@ -380,7 +380,7 @@ testXFTPAgentReceiveCleanup = withGlobalLogging logCfgNoLogs $ do -- receive file - should not succeed with server down rfId <- withAgent 2 agentCfg initAgentServers testDB2 $ \rcp -> runRight $ do xftpStartWorkers rcp (Just recipientFiles) - rfId <- xftpReceiveFile rcp 1 rfd Nothing + rfId <- xftpReceiveFile rcp 1 rfd Nothing True liftIO $ timeout 300000 (get rcp) `shouldReturn` Nothing -- wait for worker attempt pure rfId @@ -392,8 +392,7 @@ testXFTPAgentReceiveCleanup = withGlobalLogging logCfgNoLogs $ do -- receive file - should fail with AUTH error withAgent 3 agentCfg initAgentServers testDB2 $ \rcp' -> do runRight_ $ xftpStartWorkers rcp' (Just recipientFiles) - ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- - rfGet rcp' + ("", rfId', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000" AUTH)) <- rfGet rcp' rfId' `shouldBe` rfId -- tmp path should be removed after permanent error @@ -507,8 +506,8 @@ testXFTPAgentDelete = withGlobalLogging logCfgNoLogs $ -- receive file - should fail with AUTH error withAgent 3 agentCfg initAgentServers testDB2 $ \rcp2 -> runRight $ do xftpStartWorkers rcp2 (Just recipientFiles) - rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing - ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing True + ("", rfId', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000" AUTH)) <- rfGet rcp2 liftIO $ rfId' `shouldBe` rfId @@ -545,8 +544,8 @@ testXFTPAgentDeleteRestore = withGlobalLogging logCfgNoLogs $ do -- receive file - should fail with AUTH error withAgent 5 agentCfg initAgentServers testDB3 $ \rcp2 -> runRight $ do xftpStartWorkers rcp2 (Just recipientFiles) - rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing - ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfId <- xftpReceiveFile rcp2 1 rfd2 Nothing True + ("", rfId', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000" AUTH)) <- rfGet rcp2 liftIO $ rfId' `shouldBe` rfId @@ -580,8 +579,8 @@ testXFTPAgentDeleteOnServer = withGlobalLogging logCfgNoLogs $ runRight_ . void $ do -- receive file 1 again - rfId1 <- xftpReceiveFile rcp 1 rfd1_2 Nothing - ("", rfId1', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfId1 <- xftpReceiveFile rcp 1 rfd1_2 Nothing True + ("", rfId1', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000" AUTH)) <- rfGet rcp liftIO $ rfId1 `shouldBe` rfId1' @@ -613,8 +612,8 @@ testXFTPAgentExpiredOnServer = withGlobalLogging logCfgNoLogs $ do -- receive file 1 again - should fail with AUTH error runRight $ do - rfId <- xftpReceiveFile rcp 1 rfd1_2 Nothing - ("", rfId', RFERR (INTERNAL "XFTP {serverAddress = \"xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000\", xftpErr = AUTH}")) <- + rfId <- xftpReceiveFile rcp 1 rfd1_2 Nothing True + ("", rfId', RFERR (XFTP "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7000" AUTH)) <- rfGet rcp liftIO $ rfId' `shouldBe` rfId From 8fe18c4f6d1a5403e128592fab38af98cef40043 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 20 May 2024 17:48:21 +0300 Subject: [PATCH 068/107] core: use catMaybes to collect optional replies (#1161) * core: use catMaybes to collect optional replies * style * rollback agent changes and add a note --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Agent.hs | 2 +- src/Simplex/Messaging/Client.hs | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 52d9b0086..a08c5c99e 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -2093,7 +2093,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) SMP.SUB -> case respOrErr of Right SMP.OK -> processSubOk rq upConnIds Right msg@SMP.MSG {} -> do - processSubOk rq upConnIds + processSubOk rq upConnIds -- the connection is UP even when processing this particular message fails processSMP rq conn (toConnData conn) msg Right r -> processSubErr rq $ unexpectedResponse r Left e -> unless (temporaryClientError e) $ processSubErr rq e -- timeout/network was already reported diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 34aa42462..f98fac56b 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -113,7 +113,7 @@ import Data.Int (Int64) import Data.List (find) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe) +import Data.Maybe (catMaybes, fromMaybe) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime) import qualified Data.X509 as X import qualified Data.X509.Validation as XV @@ -508,14 +508,12 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize processMsgs :: ProtocolClient v err msg -> NonEmpty (SignedTransmission err msg) -> IO () processMsgs c ts = do - tsVar <- newTVarIO [] - mapM_ (processMsg c tsVar) ts - ts' <- readTVarIO tsVar + ts' <- catMaybes <$> mapM (processMsg c) (L.toList ts) forM_ msgQ $ \q -> mapM_ (atomically . writeTBQueue q . serverTransmission c) (L.nonEmpty ts') - processMsg :: ProtocolClient v err msg -> TVar [(EntityId, ServerTransmission err msg)] -> SignedTransmission err msg -> IO () - processMsg ProtocolClient {client_ = PClient {sentCommands}} tsVar (_, _, (corrId, entId, respOrErr)) + processMsg :: ProtocolClient v err msg -> SignedTransmission err msg -> IO (Maybe (EntityId, ServerTransmission err msg)) + processMsg ProtocolClient {client_ = PClient {sentCommands}} (_, _, (corrId, entId, respOrErr)) | B.null $ bs corrId = sendMsg $ STEvent clientResp | otherwise = atomically (TM.lookup corrId sentCommands) >>= \case @@ -528,7 +526,9 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize (swapTVar pending False) (True <$ tryPutTMVar responseVar (if entityId == entId then clientResp else Left unexpected)) (pure False) - unless wasPending $ sendMsg $ if entityId == entId then STResponse command clientResp else STUnexpectedError unexpected + if wasPending + then pure Nothing + else sendMsg $ if entityId == entId then STResponse command clientResp else STUnexpectedError unexpected where unexpected = unexpectedResponse respOrErr clientResp = case respOrErr of @@ -536,12 +536,13 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize Right r -> case protocolError r of Just e -> Left $ PCEProtocolError e _ -> Right r - sendMsg :: ServerTransmission err msg -> IO () + sendMsg :: ServerTransmission err msg -> IO (Maybe (EntityId, ServerTransmission err msg)) sendMsg t = case msgQ of - Just _ -> atomically $ modifyTVar' tsVar ((entId, t) :) - Nothing -> case clientResp of - Left e -> logError ("SMP client error: " <> tshow e) - Right _ -> logWarn ("SMP client unprocessed event") + Just _ -> pure $ Just (entId, t) + Nothing -> + Nothing <$ case clientResp of + Left e -> logError $ "SMP client error: " <> tshow e + Right _ -> logWarn "SMP client unprocessed event" unexpectedResponse :: Show r => r -> ProtocolClientError err unexpectedResponse = PCEUnexpectedResponse . B.pack . take 32 . show From f89d715a995312d76dbe8b6d74667a86fd12da58 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Mon, 20 May 2024 19:07:33 +0300 Subject: [PATCH 069/107] smp server: add proxy stats (#1157) * smp-server: add proxy counters * count simplex.im messages * update * fix * get own servers from INI * remove export --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Client.hs | 1 - src/Simplex/Messaging/Client/Agent.hs | 41 +++++-- src/Simplex/Messaging/Server.hs | 144 ++++++++++++++-------- src/Simplex/Messaging/Server/Env/STM.hs | 2 +- src/Simplex/Messaging/Server/Main.hs | 6 + src/Simplex/Messaging/Server/Stats.hs | 155 +++++++++++++++++++++--- tests/ServerTests.hs | 6 +- 7 files changed, 271 insertions(+), 84 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index f98fac56b..ecf4ee766 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -84,7 +84,6 @@ module Simplex.Messaging.Client ServerTransmissionBatch, ServerTransmission (..), ClientCommand, - HostMode (..), -- * For testing PCTransmission, diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 10b83157f..a7732b4d4 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -54,7 +54,7 @@ import UnliftIO.Exception (Exception) import qualified UnliftIO.Exception as E import UnliftIO.STM -type SMPClientVar = SessionVar (Either (SMPClientError, Maybe UTCTime) SMPClient) +type SMPClientVar = SessionVar (Either (SMPClientError, Maybe UTCTime) (OwnServer, SMPClient)) data SMPClientAgentEvent = CAConnected SMPServer @@ -75,7 +75,8 @@ data SMPClientAgentConfig = SMPClientAgentConfig persistErrorInterval :: NominalDiffTime, msgQSize :: Natural, agentQSize :: Natural, - agentSubsBatchSize :: Int + agentSubsBatchSize :: Int, + ownServerDomains :: [ByteString] } defaultSMPClientAgentConfig :: SMPClientAgentConfig @@ -91,7 +92,8 @@ defaultSMPClientAgentConfig = persistErrorInterval = 0, msgQSize = 256, agentQSize = 256, - agentSubsBatchSize = 900 + agentSubsBatchSize = 900, + ownServerDomains = [] } where second = 1000000 @@ -103,13 +105,15 @@ data SMPClientAgent = SMPClientAgent agentQ :: TBQueue SMPClientAgentEvent, randomDrg :: TVar ChaChaDRG, smpClients :: TMap SMPServer SMPClientVar, - smpSessions :: TMap SessionId SMPClient, + smpSessions :: TMap SessionId (OwnServer, SMPClient), srvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), pendingSrvSubs :: TMap SMPServer (TMap SMPSub C.APrivateAuthKey), smpSubWorkers :: TMap SMPServer (SessionVar (Async ())), workerSeq :: TVar Int } +type OwnServer = Bool + newtype InternalException e = InternalException {unInternalException :: e} deriving (Eq, Show) @@ -163,13 +167,17 @@ newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg -- | Get or create SMP client for SMPServer getSMPServerClient' :: SMPClientAgent -> SMPServer -> ExceptT SMPClientError IO SMPClient -getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, workerSeq} srv = +getSMPServerClient' ca srv = snd <$> getSMPServerClient'' ca srv +{-# INLINE getSMPServerClient' #-} + +getSMPServerClient'' :: SMPClientAgent -> SMPServer -> ExceptT SMPClientError IO (OwnServer, SMPClient) +getSMPServerClient'' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, workerSeq} srv = atomically getClientVar >>= either (ExceptT . newSMPClient) waitForSMPClient where getClientVar :: STM (Either SMPClientVar SMPClientVar) getClientVar = getSessVar workerSeq srv smpClients - waitForSMPClient :: SMPClientVar -> ExceptT SMPClientError IO SMPClient + waitForSMPClient :: SMPClientVar -> ExceptT SMPClientError IO (OwnServer, SMPClient) waitForSMPClient v = do let ProtocolClientConfig {networkConfig = NetworkConfig {tcpConnectTimeout}} = smpCfg agentCfg smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) @@ -179,20 +187,22 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, worker Just (Left (e, Just ts)) -> ifM ((ts <) <$> liftIO getCurrentTime) - (atomically (removeSessVar v srv smpClients) >> getSMPServerClient' ca srv) + (atomically (removeSessVar v srv smpClients) >> getSMPServerClient'' ca srv) (throwE e) Nothing -> throwE PCEResponseTimeout - newSMPClient :: SMPClientVar -> IO (Either SMPClientError SMPClient) + newSMPClient :: SMPClientVar -> IO (Either SMPClientError (OwnServer, SMPClient)) newSMPClient v = do r <- connectClient ca srv v `E.catch` (pure . Left . PCEIOError) case r of Right smp -> do logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv + let c = (isOwnServer ca srv, smp) atomically $ do - putTMVar (sessionVar v) (Right smp) - TM.insert (sessionId $ thParams smp) smp smpSessions + putTMVar (sessionVar v) (Right c) + TM.insert (sessionId $ thParams smp) c smpSessions notify ca $ CAConnected srv + pure $ Right c Left e -> do if persistErrorInterval agentCfg == 0 || e == PCENetworkError || e == PCEResponseTimeout then atomically $ do @@ -202,7 +212,12 @@ getSMPServerClient' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, worker ts <- addUTCTime (persistErrorInterval agentCfg) <$> liftIO getCurrentTime atomically $ putTMVar (sessionVar v) (Left (e, Just ts)) reconnectClient ca srv - pure r + pure $ Left e + +isOwnServer :: SMPClientAgent -> SMPServer -> OwnServer +isOwnServer SMPClientAgent {agentCfg} ProtocolServer {host} = + let srv = strEncode $ L.head host + in any (\s -> s == srv || (B.cons '.' s) `B.isSuffixOf` srv) (ownServerDomains agentCfg) -- | Run an SMP client for SMPClientVar connectClient :: SMPClientAgent -> SMPServer -> SMPClientVar -> IO (Either SMPClientError SMPClient) @@ -298,7 +313,7 @@ notify :: MonadIO m => SMPClientAgent -> SMPClientAgentEvent -> m () notify ca evt = atomically $ writeTBQueue (agentQ ca) evt {-# INLINE notify #-} -lookupSMPServerClient :: SMPClientAgent -> SessionId -> STM (Maybe SMPClient) +lookupSMPServerClient :: SMPClientAgent -> SessionId -> STM (Maybe (OwnServer, SMPClient)) lookupSMPServerClient SMPClientAgent {smpSessions} sessId = TM.lookup sessId smpSessions closeSMPClientAgent :: SMPClientAgent -> IO () @@ -315,7 +330,7 @@ closeSMPServerClients c = atomically (smpClients c `swapTVar` M.empty) >>= mapM_ where closeClient v = atomically (readTMVar $ sessionVar v) >>= \case - Right smp -> closeProtocolClient smp `catchAll_` pure () + Right (_, smp) -> closeProtocolClient smp `catchAll_` pure () _ -> pure () cancelActions :: Foldable f => TVar (f (Async ())) -> IO () diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 6c8a18aa5..6cbc4d4aa 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -6,6 +6,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedLists #-} @@ -68,8 +69,8 @@ import GHC.Stats (getRTSStats) import GHC.TypeLits (KnownNat) import Network.Socket (ServiceName, Socket, socketToHandle) import Simplex.Messaging.Agent.Lock -import Simplex.Messaging.Client (ProtocolClient (thParams), ProtocolClientError (..), forwardSMPMessage, smpProxyError) -import Simplex.Messaging.Client.Agent (SMPClientAgent (..), SMPClientAgentEvent (..), closeSMPClientAgent, getSMPServerClient', lookupSMPServerClient) +import Simplex.Messaging.Client (ProtocolClient (thParams), ProtocolClientError (..), forwardSMPMessage, smpProxyError, temporaryClientError) +import Simplex.Messaging.Client.Agent (OwnServer, SMPClientAgent (..), SMPClientAgentEvent (..), closeSMPClientAgent, getSMPServerClient'', isOwnServer, lookupSMPServerClient) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -232,7 +233,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do initialDelay <- (startAt -) . fromIntegral . (`div` 1000000_000000) . diffTimeToPicoseconds . utctDayTime <$> liftIO getCurrentTime liftIO $ putStrLn $ "server stats log enabled: " <> statsFilePath liftIO $ threadDelay' $ 1000000 * (initialDelay + if initialDelay < 0 then 86400 else 0) - ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, qCount, msgCount} <- asks serverStats + ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, qCount, msgCount, pRelays, pRelaysOwn, pMsgFwds, pMsgFwdsOwn, pMsgFwdsRecv} <- asks serverStats let interval = 1000000 * logInterval forever $ do withFile statsFilePath AppendMode $ \h -> liftIO $ do @@ -251,32 +252,46 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do msgSentNtf' <- atomically $ swapTVar msgSentNtf 0 msgRecvNtf' <- atomically $ swapTVar msgRecvNtf 0 psNtf <- atomically $ periodStatCounts activeQueuesNtf ts + pRelays' <- atomically $ getResetProxyStatsData pRelays + pRelaysOwn' <- atomically $ getResetProxyStatsData pRelaysOwn + pMsgFwds' <- atomically $ getResetProxyStatsData pMsgFwds + pMsgFwdsOwn' <- atomically $ getResetProxyStatsData pMsgFwdsOwn + pMsgFwdsRecv' <- atomically $ swapTVar pMsgFwdsRecv 0 qCount' <- readTVarIO qCount msgCount' <- readTVarIO msgCount hPutStrLn h $ intercalate "," - [ iso8601Show $ utctDay fromTime', - show qCreated', - show qSecured', - show qDeletedAll', - show msgSent', - show msgRecv', - dayCount ps, - weekCount ps, - monthCount ps, - show msgSentNtf', - show msgRecvNtf', - dayCount psNtf, - weekCount psNtf, - monthCount psNtf, - show qCount', - show msgCount', - show msgExpired', - show qDeletedNew', - show qDeletedSecured' - ] + ( [ iso8601Show $ utctDay fromTime', + show qCreated', + show qSecured', + show qDeletedAll', + show msgSent', + show msgRecv', + dayCount ps, + weekCount ps, + monthCount ps, + show msgSentNtf', + show msgRecvNtf', + dayCount psNtf, + weekCount psNtf, + monthCount psNtf, + show qCount', + show msgCount', + show msgExpired', + show qDeletedNew', + show qDeletedSecured' + ] + <> showProxyStats pRelays' + <> showProxyStats pRelaysOwn' + <> showProxyStats pMsgFwds' + <> showProxyStats pMsgFwdsOwn' + <> [show pMsgFwdsRecv'] + ) liftIO $ threadDelay' interval + where + showProxyStats ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} = + [show _pRequests, show _pSuccesses, show _pErrorsConnect, show _pErrorsCompat, show _pErrorsOther] runClient :: Transport c => C.APrivateSignKey -> TProxy c -> c -> M () runClient signKey tp h = do @@ -346,7 +361,13 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do subscriptions' <- bshow . M.size <$> readTVarIO subscriptions hPutStrLn h . B.unpack $ B.intercalate "," [bshow cid, encode sessionId, connected', strEncode createdAt, rcvActiveAt', sndActiveAt', bshow age, subscriptions'] CPStats -> withAdminRole $ do - ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgSentNtf, msgRecvNtf, qCount, msgCount} <- unliftIO u $ asks serverStats + ss <- unliftIO u $ asks serverStats + let putStat :: Show a => ByteString -> (ServerStats -> TVar a) -> IO () + putStat label var = readTVarIO (var ss) >>= \v -> B.hPutStr h $ label <> ": " <> bshow v <> "\n" + putProxyStat :: ByteString -> (ServerStats -> ProxyStats) -> IO () + putProxyStat label var = do + ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} <- atomically $ getProxyStatsData $ var ss + B.hPutStr h $ label <> ": requests=" <> bshow _pRequests <> ", successes=" <> bshow _pSuccesses <> ", errorsConnect=" <> bshow _pErrorsConnect <> ", errorsCompat=" <> bshow _pErrorsCompat <> ", errorsOther=" <> bshow _pErrorsOther <> "\n" putStat "fromTime" fromTime putStat "qCreated" qCreated putStat "qSecured" qSecured @@ -359,9 +380,11 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do putStat "msgRecvNtf" msgRecvNtf putStat "qCount" qCount putStat "msgCount" msgCount - where - putStat :: Show a => String -> TVar a -> IO () - putStat label var = readTVarIO var >>= \v -> hPutStrLn h $ label <> ": " <> show v + putProxyStat "pRelays" pRelays + putProxyStat "pRelaysOwn" pRelaysOwn + putProxyStat "pMsgFwds" pMsgFwds + putProxyStat "pMsgFwdsOwn" pMsgFwdsOwn + putStat "pMsgFwdsRecv" pMsgFwdsRecv CPStatsRTS -> getRTSStats >>= hPrint h CPThreads -> withAdminRole $ do #if MIN_VERSION_base(4,18,0) @@ -647,33 +670,56 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi ServerConfig {allowSMPProxy, newQueueBasicAuth} <- asks config pure $ allowSMPProxy && maybe True ((== auth) . Just) newQueueBasicAuth getRelay = do - ProxyAgent {smpAgent} <- asks proxyAgent - liftIO $ proxyResp <$> runExceptT (getSMPServerClient' smpAgent srv) `catch` (pure . Left . PCEIOError) + ServerStats {pRelays, pRelaysOwn} <- asks serverStats + let inc = mkIncProxyStats pRelays pRelaysOwn + ProxyAgent {smpAgent = a} <- asks proxyAgent + liftIO (runExceptT (getSMPServerClient'' a srv) `catch` (pure . Left . PCEIOError)) >>= \case + Right (own, smp) -> do + inc own pRequests + case proxyResp smp of + r@PKEY {} -> r <$ inc own pSuccesses + r -> r <$ inc own pErrorsCompat + Left e -> do + let own = isOwnServer a srv + inc own pRequests + inc own $ if temporaryClientError e then pErrorsConnect else pErrorsOther + pure . ERR $ smpProxyError e where - proxyResp = \case - Left err -> ERR $ smpProxyError err - Right smp -> - let THandleParams {sessionId = srvSessId, thVersion, thServerVRange, thAuth} = thParams smp - in case compatibleVRange thServerVRange proxiedSMPRelayVRange of - -- Cap the destination relay version range to prevent client version fingerprinting. - -- See comment for proxiedSMPRelayVersion. - Just (Compatible vr) | thVersion >= sendingProxySMPVersion -> case thAuth of - Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey - Nothing -> ERR $ transportErr TENoServerAuth - _ -> ERR $ transportErr TEVersion + proxyResp smp = + let THandleParams {sessionId = srvSessId, thVersion, thServerVRange, thAuth} = thParams smp + in case compatibleVRange thServerVRange proxiedSMPRelayVRange of + -- Cap the destination relay version range to prevent client version fingerprinting. + -- See comment for proxiedSMPRelayVersion. + Just (Compatible vr) | thVersion >= sendingProxySMPVersion -> case thAuth of + Just THAuthClient {serverCertKey} -> PKEY srvSessId vr serverCertKey + Nothing -> ERR $ transportErr TENoServerAuth + _ -> ERR $ transportErr TEVersion PFWD fwdV pubKey encBlock -> do - ProxyAgent {smpAgent} <- asks proxyAgent - atomically (lookupSMPServerClient smpAgent sessId) >>= \case - Just smp - | v >= sendingProxySMPVersion -> - liftIO $ either (ERR . smpProxyError) PRES <$> - runExceptT (forwardSMPMessage smp corrId fwdV pubKey encBlock) `catchError` (pure . Left . PCEIOError) - | otherwise -> pure . ERR $ transportErr TEVersion + ProxyAgent {smpAgent = a} <- asks proxyAgent + ServerStats {pMsgFwds, pMsgFwdsOwn} <- asks serverStats + let inc = mkIncProxyStats pMsgFwds pMsgFwdsOwn + atomically (lookupSMPServerClient a sessId) >>= \case + Just (own, smp) -> do + inc own pRequests + if + | v >= sendingProxySMPVersion -> + liftIO (runExceptT (forwardSMPMessage smp corrId fwdV pubKey encBlock) `catch` (pure . Left . PCEIOError)) >>= \case + Right r -> PRES r <$ inc own pSuccesses + Left e -> case e of + PCEProtocolError {} -> ERR err <$ inc own pSuccesses + _ -> ERR err <$ inc own pErrorsOther + where + err = smpProxyError e + | otherwise -> ERR (transportErr TEVersion) <$ inc own pErrorsCompat where THandleParams {thVersion = v} = thParams smp - Nothing -> pure $ ERR $ PROXY NO_SESSION + Nothing -> inc False pRequests >> inc False pErrorsConnect $> ERR (PROXY NO_SESSION) transportErr :: TransportError -> ErrorType transportErr = PROXY . BROKER . TRANSPORT + mkIncProxyStats :: MonadIO m => ProxyStats -> ProxyStats -> OwnServer -> (ProxyStats -> TVar Int) -> m () + mkIncProxyStats ps psOwn = \own sel -> do + atomically $ modifyTVar' (sel ps) (+ 1) + when own $ atomically $ modifyTVar' (sel psOwn) (+ 1) processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Either (Transmission (Command 'ProxiedClient)) (Transmission BrokerMsg)) processCommand (qr_, (corrId, queueId, cmd)) = do st <- asks queueStore @@ -987,6 +1033,8 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi -- encrypt to proxy let fr = FwdResponse {fwdCorrId, fwdResponse = r2} r3 = EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) + stats <- asks serverStats + atomically $ modifyTVar' (pMsgFwdsRecv stats) (+ 1) pure $ RRES r3 where rejectOrVerify :: Maybe (THandleAuth 'TServer) -> SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index f23192aeb..52a6094bc 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -126,7 +126,7 @@ data Server = Server savingLock :: Lock } -data ProxyAgent = ProxyAgent +newtype ProxyAgent = ProxyAgent { smpAgent :: SMPClientAgent } diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index fa1698653..6af1ce2a5 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -10,6 +10,7 @@ module Simplex.Messaging.Server.Main where import Control.Concurrent.STM import Control.Monad (void) +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) import Data.Ini (lookupValue, readIniFile) @@ -147,6 +148,8 @@ smpServerCLI cfgPath logPath = \# It defines prefferred hostname for destination servers with multiple hostnames.\n\ \# host_mode: public\n\ \# required_host_mode: off\n\n\ + \# The domain suffixes of the relays you operate (space-separated) to count as separate proxy statistics.\n\ + \# own_server_domains: \n\n\ \# SOCKS proxy port for forwarding messages to destination servers.\n\ \# You may need a separate instance of SOCKS proxy for incoming single-hop requests.\n\ \# socks_proxy: localhost:9050\n\n\ @@ -245,6 +248,7 @@ smpServerCLI cfgPath logPath = requiredHostMode = fromMaybe False $ iniOnOff "PROXY" "required_host_mode" ini } }, + ownServerDomains = either (const []) textToOwnServers $ lookupValue "PROXY" "own_server_domains" ini, persistErrorInterval = 30 -- seconds }, allowSMPProxy = True @@ -259,6 +263,8 @@ smpServerCLI cfgPath logPath = "public" -> HMPublic "onion" -> HMOnionViaSocks s -> error . T.unpack $ "Invalid host_mode: " <> s + textToOwnServers :: Text -> [ByteString] + textToOwnServers = map encodeUtf8 . T.words data CliCommand = Init InitOptions diff --git a/src/Simplex/Messaging/Server/Stats.hs b/src/Simplex/Messaging/Server/Stats.hs index 0b4c677c2..d8935b44b 100644 --- a/src/Simplex/Messaging/Server/Stats.hs +++ b/src/Simplex/Messaging/Server/Stats.hs @@ -33,6 +33,11 @@ data ServerStats = ServerStats msgSentNtf :: TVar Int, msgRecvNtf :: TVar Int, activeQueuesNtf :: PeriodStats RecipientId, + pRelays :: ProxyStats, + pRelaysOwn :: ProxyStats, + pMsgFwds :: ProxyStats, + pMsgFwdsOwn :: ProxyStats, + pMsgFwdsRecv :: TVar Int, qCount :: TVar Int, msgCount :: TVar Int } @@ -51,6 +56,11 @@ data ServerStatsData = ServerStatsData _msgSentNtf :: Int, _msgRecvNtf :: Int, _activeQueuesNtf :: PeriodStatsData RecipientId, + _pRelays :: ProxyStatsData, + _pRelaysOwn :: ProxyStatsData, + _pMsgFwds :: ProxyStatsData, + _pMsgFwdsOwn :: ProxyStatsData, + _pMsgFwdsRecv :: Int, _qCount :: Int, _msgCount :: Int } @@ -71,9 +81,14 @@ newServerStats ts = do msgSentNtf <- newTVar 0 msgRecvNtf <- newTVar 0 activeQueuesNtf <- newPeriodStats + pRelays <- newProxyStats + pRelaysOwn <- newProxyStats + pMsgFwds <- newProxyStats + pMsgFwdsOwn <- newProxyStats + pMsgFwdsRecv <- newTVar 0 qCount <- newTVar 0 msgCount <- newTVar 0 - pure ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, qCount, msgCount} + pure ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, pRelays, pRelaysOwn, pMsgFwds, pMsgFwdsOwn, pMsgFwdsRecv, qCount, msgCount} getServerStatsData :: ServerStats -> STM ServerStatsData getServerStatsData s = do @@ -90,9 +105,14 @@ getServerStatsData s = do _msgSentNtf <- readTVar $ msgSentNtf s _msgRecvNtf <- readTVar $ msgRecvNtf s _activeQueuesNtf <- getPeriodStatsData $ activeQueuesNtf s + _pRelays <- getProxyStatsData $ pRelays s + _pRelaysOwn <- getProxyStatsData $ pRelaysOwn s + _pMsgFwds <- getProxyStatsData $ pMsgFwds s + _pMsgFwdsOwn <- getProxyStatsData $ pMsgFwdsOwn s + _pMsgFwdsRecv <- readTVar $ pMsgFwdsRecv s _qCount <- readTVar $ qCount s _msgCount <- readTVar $ msgCount s - pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _activeQueues, _msgSentNtf, _msgRecvNtf, _activeQueuesNtf, _qCount, _msgCount} + pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _activeQueues, _msgSentNtf, _msgRecvNtf, _activeQueuesNtf, _pRelays, _pRelaysOwn, _pMsgFwds, _pMsgFwdsOwn, _pMsgFwdsRecv, _qCount, _msgCount} setServerStats :: ServerStats -> ServerStatsData -> STM () setServerStats s d = do @@ -109,28 +129,42 @@ setServerStats s d = do writeTVar (msgSentNtf s) $! _msgSentNtf d writeTVar (msgRecvNtf s) $! _msgRecvNtf d setPeriodStats (activeQueuesNtf s) (_activeQueuesNtf d) + setProxyStats (pRelays s) $! _pRelays d + setProxyStats (pRelaysOwn s) $! _pRelaysOwn d + setProxyStats (pMsgFwds s) $! _pMsgFwds d + setProxyStats (pMsgFwdsOwn s) $! _pMsgFwdsOwn d + writeTVar (pMsgFwdsRecv s) $! _pMsgFwdsRecv d writeTVar (qCount s) $! _qCount d writeTVar (msgCount s) $! _msgCount d instance StrEncoding ServerStatsData where - strEncode ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _msgSentNtf, _msgRecvNtf, _activeQueues, _activeQueuesNtf, _qCount, _msgCount} = + strEncode d = B.unlines - [ "fromTime=" <> strEncode _fromTime, - "qCreated=" <> strEncode _qCreated, - "qSecured=" <> strEncode _qSecured, - "qDeletedAll=" <> strEncode _qDeletedAll, - "qDeletedNew=" <> strEncode _qDeletedNew, - "qDeletedSecured=" <> strEncode _qDeletedSecured, - "qCount=" <> strEncode _qCount, - "msgSent=" <> strEncode _msgSent, - "msgRecv=" <> strEncode _msgRecv, - "msgExpired=" <> strEncode _msgExpired, - "msgSentNtf=" <> strEncode _msgSentNtf, - "msgRecvNtf=" <> strEncode _msgRecvNtf, + [ "fromTime=" <> strEncode (_fromTime d), + "qCreated=" <> strEncode (_qCreated d), + "qSecured=" <> strEncode (_qSecured d), + "qDeletedAll=" <> strEncode (_qDeletedAll d), + "qDeletedNew=" <> strEncode (_qDeletedNew d), + "qDeletedSecured=" <> strEncode (_qDeletedSecured d), + "qCount=" <> strEncode (_qCount d), + "msgSent=" <> strEncode (_msgSent d), + "msgRecv=" <> strEncode (_msgRecv d), + "msgExpired=" <> strEncode (_msgExpired d), + "msgSentNtf=" <> strEncode (_msgSentNtf d), + "msgRecvNtf=" <> strEncode (_msgRecvNtf d), "activeQueues:", - strEncode _activeQueues, + strEncode (_activeQueues d), "activeQueuesNtf:", - strEncode _activeQueuesNtf + strEncode (_activeQueuesNtf d), + "pRelays:", + strEncode (_pRelays d), + "pRelaysOwn:", + strEncode (_pRelaysOwn d), + "pMsgFwds:", + strEncode (_pMsgFwds d), + "pMsgFwdsOwn:", + strEncode (_pMsgFwdsOwn d), + "pMsgFwdsRecv=" <> strEncode (_pMsgFwdsRecv d) ] strP = do _fromTime <- "fromTime=" *> strP <* A.endOfLine @@ -157,7 +191,17 @@ instance StrEncoding ServerStatsData where optional ("activeQueuesNtf:" <* A.endOfLine) >>= \case Just _ -> strP <* optional A.endOfLine _ -> pure newPeriodStatsData - pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _msgSentNtf, _msgRecvNtf, _activeQueues, _activeQueuesNtf, _qCount, _msgCount = 0} + _pRelays <- proxyStatsP "pRelays:" + _pRelaysOwn <- proxyStatsP "pRelaysOwn:" + _pMsgFwds <- proxyStatsP "pMsgFwds:" + _pMsgFwdsOwn <- proxyStatsP "pMsgFwdsOwn:" + _pMsgFwdsRecv <- "pMsgFwdsRecv=" *> strP <* A.endOfLine <|> pure 0 + pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _msgSentNtf, _msgRecvNtf, _activeQueues, _activeQueuesNtf, _pRelays, _pRelaysOwn, _pMsgFwds, _pMsgFwdsOwn, _pMsgFwdsRecv, _qCount, _msgCount = 0} + where + proxyStatsP key = + optional (A.string key >> A.endOfLine) >>= \case + Just _ -> strP <* optional A.endOfLine + _ -> pure newProxyStatsData data PeriodStats a = PeriodStats { day :: TVar (Set a), @@ -231,3 +275,78 @@ updatePeriodStats stats pId = do updatePeriod month where updatePeriod pSel = modifyTVar' (pSel stats) (S.insert pId) + +data ProxyStats = ProxyStats + { pRequests :: TVar Int, + pSuccesses :: TVar Int, -- includes destination server error responses that will be forwarded to the client + pErrorsConnect :: TVar Int, + pErrorsCompat :: TVar Int, + pErrorsOther :: TVar Int + } + +newProxyStats :: STM ProxyStats +newProxyStats = do + pRequests <- newTVar 0 + pSuccesses <- newTVar 0 + pErrorsConnect <- newTVar 0 + pErrorsCompat <- newTVar 0 + pErrorsOther <- newTVar 0 + pure ProxyStats {pRequests, pSuccesses, pErrorsConnect, pErrorsCompat, pErrorsOther} + +data ProxyStatsData = ProxyStatsData + { _pRequests :: Int, + _pSuccesses :: Int, + _pErrorsConnect :: Int, + _pErrorsCompat :: Int, + _pErrorsOther :: Int + } + deriving (Show) + +newProxyStatsData :: ProxyStatsData +newProxyStatsData = ProxyStatsData {_pRequests = 0, _pSuccesses = 0, _pErrorsConnect = 0, _pErrorsCompat = 0, _pErrorsOther = 0} + +getProxyStatsData :: ProxyStats -> STM ProxyStatsData +getProxyStatsData s = do + _pRequests <- readTVar $ pRequests s + _pSuccesses <- readTVar $ pSuccesses s + _pErrorsConnect <- readTVar $ pErrorsConnect s + _pErrorsCompat <- readTVar $ pErrorsCompat s + _pErrorsOther <- readTVar $ pErrorsOther s + pure ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} + +getResetProxyStatsData :: ProxyStats -> STM ProxyStatsData +getResetProxyStatsData s = do + _pRequests <- swapTVar (pRequests s) 0 + _pSuccesses <- swapTVar (pSuccesses s) 0 + _pErrorsConnect <- swapTVar (pErrorsConnect s) 0 + _pErrorsCompat <- swapTVar (pErrorsCompat s) 0 + _pErrorsOther <- swapTVar (pErrorsOther s) 0 + pure ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} + +setProxyStats :: ProxyStats -> ProxyStatsData -> STM () +setProxyStats s d = do + writeTVar (pRequests s) $! _pRequests d + writeTVar (pSuccesses s) $! _pSuccesses d + writeTVar (pErrorsConnect s) $! _pErrorsConnect d + writeTVar (pErrorsCompat s) $! _pErrorsCompat d + writeTVar (pErrorsOther s) $! _pErrorsOther d + +instance StrEncoding ProxyStatsData where + strEncode ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} = + "requests=" + <> strEncode _pRequests + <> "\nsuccesses=" + <> strEncode _pSuccesses + <> "\nerrorsConnect=" + <> strEncode _pErrorsConnect + <> "\nerrorsCompat=" + <> strEncode _pErrorsCompat + <> "\nerrorsOther=" + <> strEncode _pErrorsOther + strP = do + _pRequests <- "requests=" *> strP <* A.endOfLine + _pSuccesses <- "successes=" *> strP <* A.endOfLine + _pErrorsConnect <- "errorsConnect=" *> strP <* A.endOfLine + _pErrorsCompat <- "errorsCompat=" *> strP <* A.endOfLine + _pErrorsOther <- "errorsOther=" *> strP + pure ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index b0ed67913..b0c90ca96 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -608,7 +608,7 @@ testRestoreMessages at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 2 logSize testStoreMsgsFile `shouldReturn` 5 - logSize testServerStatsBackupFile `shouldReturn` 20 + logSize testServerStatsBackupFile `shouldReturn` 45 Right stats1 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats1 [rId] 5 1 @@ -626,7 +626,7 @@ testRestoreMessages at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 1 -- the last message is not removed because it was not ACK'd logSize testStoreMsgsFile `shouldReturn` 3 - logSize testServerStatsBackupFile `shouldReturn` 20 + logSize testServerStatsBackupFile `shouldReturn` 45 Right stats2 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats2 [rId] 5 3 @@ -645,7 +645,7 @@ testRestoreMessages at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 1 logSize testStoreMsgsFile `shouldReturn` 0 - logSize testServerStatsBackupFile `shouldReturn` 20 + logSize testServerStatsBackupFile `shouldReturn` 45 Right stats3 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats3 [rId] 5 5 From e3f5d244c1a435593e33adc023bf1f920f379f8d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 May 2024 17:31:08 +0100 Subject: [PATCH 070/107] 5.8.0.2 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 6b90c709f..7667d41ef 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.1 +version: 5.8.0.2 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 7d2759b46..d8cc1b54d 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.1 +version: 5.8.0.2 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From f50fa5c60b60c18044bc6c17e973f0abcfe77cac Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 21 May 2024 14:53:33 +0300 Subject: [PATCH 071/107] smp-server: limit concurrency in proxy command processing (#1162) * smp: put client proxy command processing threads under a shared semaphore * add LIMITS.max_proc_threads to server config * rename to PROXY.client_concurrency * retry on strictly greater than max concurrency * set default to 16 * rename * fix limit --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Server.hs | 41 +++++++++++++++++-------- src/Simplex/Messaging/Server/Env/STM.hs | 10 ++++-- src/Simplex/Messaging/Server/Main.hs | 9 ++++-- tests/SMPClient.hs | 3 +- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 6cbc4d4aa..c0ab7df8e 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -45,6 +45,7 @@ import Control.Monad.IO.Unlift import Control.Monad.Reader import Control.Monad.Trans.Except import Crypto.Random +import Control.Monad.STM (retry) import Data.Bifunctor (first) import Data.ByteString.Base64 (encode) import Data.ByteString.Char8 (ByteString) @@ -95,7 +96,6 @@ import System.Exit (exitFailure) import System.IO (hPrint, hPutStrLn, hSetNewlineMode, universalNewlineMode) import System.Mem.Weak (deRefWeak) import UnliftIO (timeout) -import UnliftIO.Async (mapConcurrently) import UnliftIO.Concurrent import UnliftIO.Directory (doesFileExist, renameFile) import UnliftIO.Exception @@ -182,12 +182,8 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do TM.lookupInsert qId clnt (subs s) $>>= clientToBeNotified endPreviousSubscriptions :: (QueueId, Client) -> M (Maybe s) endPreviousSubscriptions (qId, c) = do - tId <- atomically $ stateTVar (endThreadSeq c) $ \next -> (next, next + 1) - t <- forkIO $ do - labelMyThread $ label <> ".endPreviousSubscriptions" + forkClient c (label <> ".endPreviousSubscriptions") $ atomically $ writeTBQueue (sndQ c) [(CorrId "", qId, END)] - atomically $ modifyTVar' (endThreads c) $ IM.delete tId - mkWeakThreadId t >>= atomically . modifyTVar' (endThreads c) . IM.insert tId atomically $ TM.lookupDelete qId (clientSubs c) receiveFromProxyAgent :: ProxyAgent -> M () @@ -364,10 +360,10 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do ss <- unliftIO u $ asks serverStats let putStat :: Show a => ByteString -> (ServerStats -> TVar a) -> IO () putStat label var = readTVarIO (var ss) >>= \v -> B.hPutStr h $ label <> ": " <> bshow v <> "\n" - putProxyStat :: ByteString -> (ServerStats -> ProxyStats) -> IO () + putProxyStat :: ByteString -> (ServerStats -> ProxyStats) -> IO () putProxyStat label var = do ProxyStatsData {_pRequests, _pSuccesses, _pErrorsConnect, _pErrorsCompat, _pErrorsOther} <- atomically $ getProxyStatsData $ var ss - B.hPutStr h $ label <> ": requests=" <> bshow _pRequests <> ", successes=" <> bshow _pSuccesses <> ", errorsConnect=" <> bshow _pErrorsConnect <> ", errorsCompat=" <> bshow _pErrorsCompat <> ", errorsOther=" <> bshow _pErrorsOther <> "\n" + B.hPutStr h $ label <> ": requests=" <> bshow _pRequests <> ", successes=" <> bshow _pSuccesses <> ", errorsConnect=" <> bshow _pErrorsConnect <> ", errorsCompat=" <> bshow _pErrorsCompat <> ", errorsOther=" <> bshow _pErrorsOther <> "\n" putStat "fromTime" fromTime putStat "qCreated" qCreated putStat "qSecured" qSecured @@ -650,18 +646,39 @@ dummyKeyEd448 = "MEMwBQYDK2VxAzoA6ibQc9XpkSLtwrf7PLvp81qW/etiumckVFImCMRdftcG/Xo dummyKeyX25519 :: C.PublicKey 'C.X25519 dummyKeyX25519 = "MCowBQYDK2VuAyEA4JGSMYht18H4mas/jHeBwfcM7jLwNYJNOAhi2/g4RXg=" +forkClient :: Client -> String -> M () -> M () +forkClient Client {endThreads, endThreadSeq} label action = do + tId <- atomically $ stateTVar endThreadSeq $ \next -> (next, next + 1) + t <- forkIO $ do + labelMyThread label + action `finally` atomically (modifyTVar' endThreads $ IM.delete tId) + mkWeakThreadId t >>= atomically . modifyTVar' endThreads . IM.insert tId + client :: THandleParams SMPVersion 'TServer -> Client -> Server -> M () -client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId} Server {subscribedQ, ntfSubscribedQ, notifiers} = do +client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId, procThreads} Server {subscribedQ, ntfSubscribedQ, notifiers} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " commands" forever $ do (proxied, rs) <- partitionEithers . L.toList <$> (mapM processCommand =<< atomically (readTBQueue rcvQ)) forM_ (L.nonEmpty rs) reply - -- TODO cancel this thread if the client gets disconnected - -- TODO limit client concurrency - forM_ (L.nonEmpty proxied) $ \cmds -> forkIO $ mapConcurrently processProxiedCmd cmds >>= reply + forM_ (L.nonEmpty proxied) $ \cmds -> mapM forkProxiedCmd cmds >>= mapM (atomically . takeTMVar) >>= reply where reply :: MonadIO m => NonEmpty (Transmission BrokerMsg) -> m () reply = atomically . writeTBQueue sndQ + forkProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (TMVar (Transmission BrokerMsg)) + forkProxiedCmd cmd = do + res <- newEmptyTMVarIO + bracket_ wait signal . forkClient clnt (B.unpack $ "client $" <> encode sessionId <> " proxy") $ + -- commands MUST be processed under a reasonable timeout or the client would halt + processProxiedCmd cmd >>= atomically . putTMVar res + pure res + where + wait = do + ServerConfig {serverClientConcurrency} <- asks config + atomically $ do + used <- readTVar procThreads + when (used >= serverClientConcurrency) retry + writeTVar procThreads $! used + 1 + signal = atomically $ modifyTVar' procThreads (\t -> t - 1) processProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (Transmission BrokerMsg) processProxiedCmd (corrId, sessId, command) = (corrId, sessId,) <$> case command of PRXY srv auth -> ifM allowProxy getRelay (pure $ ERR $ PROXY BASIC_AUTH) diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index 52a6094bc..77adb94f4 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -82,7 +82,8 @@ data ServerConfig = ServerConfig -- | run listener on control port controlPort :: Maybe ServiceName, smpAgentCfg :: SMPClientAgentConfig, - allowSMPProxy :: Bool -- auth is the same with `newQueueBasicAuth` + allowSMPProxy :: Bool, -- auth is the same with `newQueueBasicAuth` + serverClientConcurrency :: Int } defMsgExpirationDays :: Int64 @@ -102,6 +103,9 @@ defaultInactiveClientExpiration = checkInterval = 3600 -- seconds, 1 hours } +defaultProxyClientConcurrency :: Int +defaultProxyClientConcurrency = 16 + data Env = Env { config :: ServerConfig, server :: Server, @@ -139,6 +143,7 @@ data Client = Client rcvQ :: TBQueue (NonEmpty (Maybe QueueRec, Transmission Cmd)), sndQ :: TBQueue (NonEmpty (Transmission BrokerMsg)), msgQ :: TBQueue (NonEmpty (Transmission BrokerMsg)), + procThreads :: TVar Int, endThreads :: TVar (IntMap (Weak ThreadId)), endThreadSeq :: TVar Int, thVersion :: VersionSMP, @@ -173,12 +178,13 @@ newClient nextClientId qSize thVersion sessionId createdAt = do rcvQ <- newTBQueue qSize sndQ <- newTBQueue qSize msgQ <- newTBQueue qSize + procThreads <- newTVar 0 endThreads <- newTVar IM.empty endThreadSeq <- newTVar 0 connected <- newTVar True rcvActiveAt <- newTVar createdAt sndActiveAt <- newTVar createdAt - return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, msgQ, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt} + return Client {clientId, subscriptions, ntfSubscriptions, rcvQ, sndQ, msgQ, procThreads, endThreads, endThreadSeq, thVersion, sessionId, connected, createdAt, rcvActiveAt, sndActiveAt} newSubscription :: SubscriptionThread -> STM Sub newSubscription subThread = do diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index 6af1ce2a5..980a7b8d0 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -27,7 +27,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (ProtoServerWithAuth), pattern SMPServer) import Simplex.Messaging.Server (runSMPServer) import Simplex.Messaging.Server.CLI -import Simplex.Messaging.Server.Env.STM (ServerConfig (..), defMsgExpirationDays, defaultInactiveClientExpiration, defaultMessageExpiration) +import Simplex.Messaging.Server.Env.STM (ServerConfig (..), defMsgExpirationDays, defaultInactiveClientExpiration, defaultMessageExpiration, defaultProxyClientConcurrency) import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Transport (batchCmdsSMPVersion, sendingProxySMPVersion, simplexMQVersion, supportedSMPHandshakes, supportedServerSMPRelayVRange) import Simplex.Messaging.Transport.Client (TransportHost (..)) @@ -156,7 +156,9 @@ smpServerCLI cfgPath logPath = \# `socks_mode` can be 'onion' for SOCKS proxy to be used for .onion destination hosts only (default)\n\ \# or 'always' to be used for all destination hosts (can be used if it is an .onion server).\n\ \# socks_mode: onion\n\n\ - \[INACTIVE_CLIENTS]\n\ + \# Limit number of threads a client can spawn to process proxy commands in parrallel.\n" + <> ("# client_concurrency: " <> show defaultProxyClientConcurrency <> "\n\n") + <> "[INACTIVE_CLIENTS]\n\ \# TTL and interval to check inactive clients\n\ \disconnect: off\n" <> ("# ttl: " <> show (ttl defaultInactiveClientExpiration) <> "\n") @@ -251,7 +253,8 @@ smpServerCLI cfgPath logPath = ownServerDomains = either (const []) textToOwnServers $ lookupValue "PROXY" "own_server_domains" ini, persistErrorInterval = 30 -- seconds }, - allowSMPProxy = True + allowSMPProxy = True, + serverClientConcurrency = readIniDefault defaultProxyClientConcurrency "PROXY" "client_concurrency" ini } textToSocksMode :: Text -> SocksMode textToSocksMode = \case diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index d2e11d29b..99633ac1d 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -119,7 +119,8 @@ cfg = transportConfig = defaultTransportServerConfig {Server.alpn = Just supportedSMPHandshakes}, controlPort = Nothing, smpAgentCfg = defaultSMPClientAgentConfig, - allowSMPProxy = False + allowSMPProxy = False, + serverClientConcurrency = 2 } cfgV7 :: ServerConfig From 2f2a3acdc0840b07bfc3198cf9fc2f398297b016 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 21 May 2024 14:51:11 +0100 Subject: [PATCH 072/107] 5.8.0.3 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 7667d41ef..625c30df4 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.2 +version: 5.8.0.3 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index d8cc1b54d..265df1772 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.2 +version: 5.8.0.3 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From f50589b31a5d25032a0c8ad7e3a25010d4757b3d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 21 May 2024 22:52:22 +0100 Subject: [PATCH 073/107] agent: remove external timeout to resubscribe (#1164) * agent: remove external timeout to resubscribe * liftIO * fix tests --- src/Simplex/Messaging/Agent/Client.hs | 52 ++++++++--------------- src/Simplex/Messaging/Agent/Env/SQLite.hs | 4 -- tests/AgentTests.hs | 28 ++++++------ tests/AgentTests/FunctionalAPITests.hs | 1 + tests/AgentTests/NotificationTests.hs | 17 ++++---- 5 files changed, 44 insertions(+), 58 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 2299b2183..30e1f0aa8 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -608,12 +608,11 @@ resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = atomically $ putTMVar (sessionVar v) a runSubWorker = do ri <- asks $ reconnectInterval . config - timeoutCounts <- newTVarIO 0 withRetryInterval ri $ \_ loop -> do pending <- atomically getPending forM_ (L.nonEmpty pending) $ \qs -> do - lift $ waitForUserNetwork c - void . tryAgentError' $ reconnectSMPClient timeoutCounts c tSess qs + liftIO $ waitForUserNetwork c + reconnectSMPClient c tSess qs loop getPending = RQ.getSessQueues tSess $ pendingSubs c cleanup :: SessionVar (Async ()) -> STM () @@ -623,38 +622,23 @@ resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = whenM (isEmptyTMVar $ sessionVar v) retry removeSessVar v tSess smpSubWorkers -reconnectSMPClient :: TVar Int -> AgentClient -> SMPTransportSession -> NonEmpty RcvQueue -> AM () -reconnectSMPClient tc c tSess@(_, srv, _) qs = do - NetworkConfig {tcpTimeout} <- atomically $ getNetworkConfig c - -- this allows 3x of timeout per batch of subscription (90 queues per batch empirically) - let t = (length qs `div` 90 + 1) * tcpTimeout * 3 - ExceptT (sequence <$> (t `timeout` runExceptT resubscribe)) >>= \case - Just _ -> resetTimeouts - -- reset and do not report consecutive timeouts while offline - Nothing -> ifM (atomically $ isNetworkOnline c) notifyTimeout resetTimeouts +reconnectSMPClient :: AgentClient -> SMPTransportSession -> NonEmpty RcvQueue -> AM' () +reconnectSMPClient c tSess@(_, srv, _) qs = handleNotify $ do + cs <- readTVarIO $ RQ.getConnections $ activeSubs c + rs <- subscribeQueues c $ L.toList qs + let (errs, okConns) = partitionEithers $ map (\(RcvQueue {connId}, r) -> bimap (connId,) (const connId) r) rs + conns = filter (`M.notMember` cs) okConns + unless (null conns) $ notifySub "" $ UP srv conns + let (tempErrs, finalErrs) = partition (temporaryAgentError . snd) errs + mapM_ (\(connId, e) -> notifySub connId $ ERR e) finalErrs + forM_ (listToMaybe tempErrs) $ \(connId, e) -> do + when (null okConns && M.null cs && null finalErrs) . liftIO $ + closeClient c smpClients tSess + notifySub connId $ ERR e where - resetTimeouts = atomically $ writeTVar tc 0 - notifyTimeout = do - tc' <- atomically $ stateTVar tc $ \i -> (i + 1, i + 1) - maxTC <- asks $ maxSubscriptionTimeouts . config - when (tc' >= maxTC) $ do - let msg = show tc' <> " consecutive subscription timeouts: " <> show (length qs) <> " queues, transport session: " <> show tSess - atomically $ writeTBQueue (subQ c) ("", "", APC SAEConn $ ERR $ INTERNAL msg) - resubscribe :: AM () - resubscribe = do - cs <- readTVarIO $ RQ.getConnections $ activeSubs c - rs <- lift . subscribeQueues c $ L.toList qs - let (errs, okConns) = partitionEithers $ map (\(RcvQueue {connId}, r) -> bimap (connId,) (const connId) r) rs - liftIO $ do - let conns = filter (`M.notMember` cs) okConns - unless (null conns) $ notifySub "" $ UP srv conns - let (tempErrs, finalErrs) = partition (temporaryAgentError . snd) errs - liftIO $ mapM_ (\(connId, e) -> notifySub connId $ ERR e) finalErrs - forM_ (listToMaybe tempErrs) $ \(_, err) -> do - when (null okConns && M.null cs && null finalErrs) . liftIO $ - closeClient c smpClients tSess - throwError err - notifySub :: forall e. AEntityI e => ConnId -> ACommand 'Agent e -> IO () + handleNotify :: AM' () -> AM' () + handleNotify = E.handleAny $ notifySub "" . ERR . INTERNAL . show + notifySub :: forall e. AEntityI e => ConnId -> ACommand 'Agent e -> AM' () notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) getNtfServerClient :: AgentClient -> NtfTransportSession -> AM NtfClient diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index 9613adf3c..b753a4226 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -102,7 +102,6 @@ data AgentConfig = AgentConfig cleanupInterval :: Int64, cleanupStepInterval :: Int, maxWorkerRestartsPerMin :: Int, - maxSubscriptionTimeouts :: Int, storedMsgDataTTL :: NominalDiffTime, rcvFilesTTL :: NominalDiffTime, sndFilesTTL :: NominalDiffTime, @@ -173,9 +172,6 @@ defaultAgentConfig = cleanupInterval = 30 * 60 * 1000000, -- 30 minutes cleanupStepInterval = 200000, -- 200ms maxWorkerRestartsPerMin = 5, - -- 5 consecutive subscription timeouts will result in alert to the user - -- this is a fallback, as the timeout set to 3x of expected timeout, to avoid potential locking. - maxSubscriptionTimeouts = 5, storedMsgDataTTL = 21 * nominalDay, rcvFilesTTL = 2 * nominalDay, sndFilesTTL = nominalDay, diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index 32610b54e..9572aed2f 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -95,23 +95,24 @@ type AEntityTransmission p e = (ACorrId, ConnId, ACommand p e) type AEntityTransmissionOrError p e = (ACorrId, ConnId, Either AgentErrorType (ACommand p e)) tGetAgent :: Transport c => c -> IO (AEntityTransmissionOrError 'Agent 'AEConn) -tGetAgent = tGetAgent' +tGetAgent = tGetAgent' True -tGetAgent' :: forall c e. (Transport c, AEntityI e) => c -> IO (AEntityTransmissionOrError 'Agent e) -tGetAgent' h = do - (corrId, connId, cmdOrErr) <- pGetAgent h +tGetAgent' :: forall c e. (Transport c, AEntityI e) => Bool -> c -> IO (AEntityTransmissionOrError 'Agent e) +tGetAgent' skipErr h = do + (corrId, connId, cmdOrErr) <- pGetAgent skipErr h case cmdOrErr of Right (APC e cmd) -> case testEquality e (sAEntity @e) of Just Refl -> pure (corrId, connId, Right cmd) _ -> error $ "unexpected command " <> show cmd Left err -> pure (corrId, connId, Left err) -pGetAgent :: forall c. Transport c => c -> IO (ATransmissionOrError 'Agent) -pGetAgent h = do +pGetAgent :: forall c. Transport c => Bool -> c -> IO (ATransmissionOrError 'Agent) +pGetAgent skipErr h = do (corrId, connId, cmdOrErr) <- tGet SAgent h case cmdOrErr of - Right (APC _ CONNECT {}) -> pGetAgent h - Right (APC _ DISCONNECT {}) -> pGetAgent h + Right (APC _ CONNECT {}) -> pGetAgent skipErr h + Right (APC _ DISCONNECT {}) -> pGetAgent skipErr h + Right (APC _ (ERR (BROKER _ NETWORK))) | skipErr -> pGetAgent skipErr h cmd -> pure (corrId, connId, cmd) -- | receive message to handle `h` @@ -119,15 +120,18 @@ pGetAgent h = do (<#:) = tGetAgent (<#:?) :: Transport c => c -> IO (ATransmissionOrError 'Agent) -(<#:?) = pGetAgent +(<#:?) = pGetAgent True (<#:.) :: Transport c => c -> IO (AEntityTransmissionOrError 'Agent 'AENone) -(<#:.) = tGetAgent' +(<#:.) = tGetAgent' True -- | send transmission `t` to handle `h` and get response (#:) :: Transport c => c -> (ByteString, ByteString, ByteString) -> IO (AEntityTransmissionOrError 'Agent 'AEConn) h #: t = tPutRaw h t >> (<#:) h +(#:!) :: Transport c => c -> (ByteString, ByteString, ByteString) -> IO (AEntityTransmissionOrError 'Agent 'AEConn) +h #:! t = tPutRaw h t >> tGetAgent' False h + -- | action and expected response -- `h #:t #> r` is the test that sends `t` to `h` and validates that the response is `r` (#>) :: IO (AEntityTransmissionOrError 'Agent 'AEConn) -> AEntityTransmission 'Agent 'AEConn -> Expectation @@ -426,8 +430,8 @@ testServerConnectionAfterError t _ = do withAgent1 $ \bob -> do withAgent2 $ \alice -> do - bob #: ("1", "alice", "SUB") =#> \("1", "alice", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT - alice #: ("1", "bob", "SUB") =#> \("1", "bob", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT + bob #:! ("1", "alice", "SUB") =#> \("1", "alice", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT + alice #:! ("1", "bob", "SUB") =#> \("1", "bob", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT withServer $ do alice <#=? \case ("", "bob", APC _ (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False alice <#=? \case ("", "bob", APC _ (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 7cf1ab00a..bb1d34b10 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -144,6 +144,7 @@ pGet c = do case cmd of CONNECT {} -> pGet c DISCONNECT {} -> pGet c + ERR (BROKER _ NETWORK) -> pGet c _ -> pure t pattern CONF :: ConfirmationId -> [SMPServer] -> ConnInfo -> ACommand 'Agent e diff --git a/tests/AgentTests/NotificationTests.hs b/tests/AgentTests/NotificationTests.hs index 2c1045791..cf1c4783a 100644 --- a/tests/AgentTests/NotificationTests.hs +++ b/tests/AgentTests/NotificationTests.hs @@ -6,8 +6,8 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeApplications #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} +{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module AgentTests.NotificationTests where @@ -17,10 +17,6 @@ import AgentTests.FunctionalAPITests createConnection, exchangeGreetingsMsgId, get, - withAgent, - withAgentClients2, - withAgentClientsCfgServers2, - withAgentClients3, joinConnection, makeConnection, nGet, @@ -29,7 +25,11 @@ import AgentTests.FunctionalAPITests sendMessage, switchComplete, testServerMatrix2, + withAgent, + withAgentClients2, + withAgentClients3, withAgentClientsCfg2, + withAgentClientsCfgServers2, (##>), (=##>), pattern CON, @@ -59,8 +59,8 @@ import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO) import Simplex.Messaging.Agent.Store.SQLite (getSavedNtfToken) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..)) import Simplex.Messaging.Notifications.Protocol +import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..)) import Simplex.Messaging.Notifications.Server.Push.APNS import Simplex.Messaging.Notifications.Types (NtfToken (..)) import Simplex.Messaging.Protocol (ErrorType (AUTH), MsgFlags (MsgFlags), NtfServer, ProtocolServer (..), SMPMsgMeta (..), SubscriptionMode (..)) @@ -151,7 +151,8 @@ testNtfMatrix t runTest = do it "next servers: SMP v7, NTF v2; curr clients: v6/v1" $ runNtfTestCfg t cfgV7 ntfServerCfgV2 agentCfg agentCfg runTest it "curr servers: SMP v6, NTF v1; curr clients: v6/v1" $ runNtfTestCfg t cfg ntfServerCfg agentCfg agentCfg runTest skip "this case cannot be supported - see RFC" $ - it "servers: SMP v6, NTF v1; clients: v7/v2 (not supported)" $ runNtfTestCfg t cfg ntfServerCfg agentCfgV7 agentCfgV7 runTest + it "servers: SMP v6, NTF v1; clients: v7/v2 (not supported)" $ + runNtfTestCfg t cfg ntfServerCfg agentCfgV7 agentCfgV7 runTest -- servers can be migrated in any order it "servers: next SMP v7, curr NTF v1; curr clients: v6/v1" $ runNtfTestCfg t cfgV7 ntfServerCfg agentCfg agentCfg runTest it "servers: curr SMP v6, next NTF v2; curr clients: v6/v1" $ runNtfTestCfg t cfg ntfServerCfgV2 agentCfg agentCfg runTest @@ -258,7 +259,7 @@ testNtfTokenServerRestart t APNSMockServer {apnsQ} = do atomically $ readTBQueue apnsQ liftIO $ sendApnsResponse APNSRespOk pure ntfData - -- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server + -- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server threadDelay 1000000 withAgent 2 agentCfg initAgentServers testDB $ \a' -> -- server stopped before token is verified, so now the attempt to verify it will return AUTH error but re-register token, From 769e54db76fc33a04085410cdf78ae77ed9717b6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 21 May 2024 22:58:58 +0100 Subject: [PATCH 074/107] 5.7.4.1 --- CHANGELOG.md | 7 +++++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0093ba92..e2a7bb5b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 5.7.4 + +SMP agent: +- remove re-subscription timeouts (as they are tracked per operation, and could cause failed subscriptions). +- reconnect XFTP clients when network settings changes. +- fix lock contention resulting in stuck subscriptions on network change. + # 5.7.3 SMP/NTF protocol: diff --git a/package.yaml b/package.yaml index 7ba64f531..a1f4a225a 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.4.0 +version: 5.7.4.1 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 19c99acc4..9bfa146c1 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.4.0 +version: 5.7.4.1 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 8097df5540f149347676f141b39dc27295530f30 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 22 May 2024 13:25:49 +0100 Subject: [PATCH 075/107] agent: persist server connection error (#1165) * agent: persist server connection * comment, refactor * fix tests, reduce interval for ntf server * cleanup * 0 --- src/Simplex/Messaging/Agent/Client.hs | 49 +++++++++++++------ src/Simplex/Messaging/Agent/Env/SQLite.hs | 2 + src/Simplex/Messaging/Client/Agent.hs | 20 ++++---- .../Messaging/Notifications/Server/Main.hs | 4 +- tests/AgentTests/FunctionalAPITests.hs | 37 +++++--------- tests/AgentTests/NotificationTests.hs | 1 + tests/NtfClient.hs | 6 ++- tests/SMPAgentClient.hs | 1 + tests/SMPClient.hs | 10 ++-- tests/XFTPAgent.hs | 2 +- 10 files changed, 70 insertions(+), 62 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 7d07e7366..a2d7b5f59 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -175,7 +175,7 @@ import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import Data.Text.Encoding -import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime) +import Data.Time (UTCTime, addUTCTime, defaultTimeLocale, formatTime, getCurrentTime) import Data.Time.Clock.System (getSystemTime) import Data.Word (Word16) import qualified Database.SQLite.Simple as SQL @@ -252,7 +252,7 @@ import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, removeFile) import qualified UnliftIO.Exception as E import UnliftIO.STM -type ClientVar msg = SessionVar (Either AgentErrorType (Client msg)) +type ClientVar msg = SessionVar (Either (AgentErrorType, Maybe UTCTime) (Client msg)) type SMPClientVar = ClientVar SMP.BrokerMsg @@ -584,7 +584,7 @@ getSMPServerClient :: AgentClient -> SMPTransportSession -> AM SMPConnectedClien getSMPServerClient c@AgentClient {active, smpClients, workerSeq} tSess = do unlessM (readTVarIO active) . throwError $ INACTIVE atomically (getSessVar workerSeq tSess smpClients) - >>= either newClient (waitForProtocolClient c tSess) + >>= either newClient (waitForProtocolClient c tSess smpClients) where newClient v = do prs <- atomically TM.empty @@ -613,7 +613,7 @@ getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq (clnt,) <$> newProxiedRelay clnt auth rv waitForProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) waitForProxyClient tSess auth v = do - clnt@(SMPConnectedClient _ prs) <- waitForProtocolClient c tSess v + clnt@(SMPConnectedClient _ prs) <- waitForProtocolClient c tSess smpClients v sess <- atomically (getSessVar workerSeq destSrv prs) >>= either (newProxiedRelay clnt auth) (waitForProxiedRelay tSess) @@ -744,7 +744,7 @@ getNtfServerClient c@AgentClient {active, ntfClients, workerSeq} tSess@(userId, atomically (getSessVar workerSeq tSess ntfClients) >>= either (newProtocolClient c tSess ntfClients connectClient) - (waitForProtocolClient c tSess) + (waitForProtocolClient c tSess ntfClients) where connectClient :: NtfClientVar -> AM NtfClient connectClient v = do @@ -767,7 +767,7 @@ getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId atomically (getSessVar workerSeq tSess xftpClients) >>= either (newProtocolClient c tSess xftpClients connectClient) - (waitForProtocolClient c tSess) + (waitForProtocolClient c tSess xftpClients) where connectClient :: XFTPClientVar -> AM XFTPClient connectClient v = do @@ -784,14 +784,26 @@ getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId atomically $ writeTBQueue (subQ c) ("", "", APC SAENone $ hostEvent DISCONNECT client) logInfo . decodeUtf8 $ "Agent disconnected from " <> showServer srv -waitForProtocolClient :: ProtocolTypeI (ProtoType msg) => AgentClient -> TransportSession msg -> ClientVar msg -> AM (Client msg) -waitForProtocolClient c (_, srv, _) v = do +waitForProtocolClient :: + (ProtocolTypeI (ProtoType msg), ProtocolServerClient v err msg) => + AgentClient -> + TransportSession msg -> + TMap (TransportSession msg) (ClientVar msg) -> + ClientVar msg -> + AM (Client msg) +waitForProtocolClient c tSess@(_, srv, _) clients v = do NetworkConfig {tcpConnectTimeout} <- atomically $ getNetworkConfig c client_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) - liftEither $ case client_ of - Just (Right smpClient) -> Right smpClient - Just (Left e) -> Left e - Nothing -> Left $ BROKER (B.unpack $ strEncode srv) TIMEOUT + case client_ of + Just (Right smpClient) -> pure smpClient + Just (Left (e, ts_)) -> case ts_ of + Nothing -> throwE e + Just ts -> + ifM + ((ts <) <$> liftIO getCurrentTime) + (atomically (removeSessVar v tSess clients) >> getProtocolServerClient c tSess) + (throwE e) + Nothing -> throwE $ BROKER (B.unpack $ strEncode srv) TIMEOUT -- clientConnected arg is only passed for SMP server newProtocolClient :: @@ -813,10 +825,15 @@ newProtocolClient c tSess@(userId, srv, entityId_) clients connectClient v = pure client Left e -> do liftIO $ incServerStat c userId srv "CLIENT" $ strEncode e - atomically $ do - removeSessVar v tSess clients - putTMVar (sessionVar v) (Left e) - throwError e -- signal error to caller + ei <- asks $ persistErrorInterval . config + if ei == 0 + then atomically $ do + removeSessVar v tSess clients + putTMVar (sessionVar v) (Left (e, Nothing)) + else do + ts <- addUTCTime ei <$> liftIO getCurrentTime + atomically $ putTMVar (sessionVar v) (Left (e, Just ts)) + throwE e -- signal error to caller hostEvent :: forall v err msg. (ProtocolTypeI (ProtoType msg), ProtocolServerClient v err msg) => (AProtocolType -> TransportHost -> ACommand 'Agent 'AENone) -> Client msg -> ACommand 'Agent 'AENone hostEvent event = hostEvent' event . protocolClient diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index b753a4226..ccefe9c22 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -98,6 +98,7 @@ data AgentConfig = AgentConfig connDeleteDeliveryTimeout :: NominalDiffTime, helloTimeout :: NominalDiffTime, quotaExceededTimeout :: NominalDiffTime, + persistErrorInterval :: NominalDiffTime, initialCleanupDelay :: Int64, cleanupInterval :: Int64, cleanupStepInterval :: Int, @@ -168,6 +169,7 @@ defaultAgentConfig = connDeleteDeliveryTimeout = 2 * nominalDay, helloTimeout = 2 * nominalDay, quotaExceededTimeout = 7 * nominalDay, + persistErrorInterval = 10, -- seconds initialCleanupDelay = 30 * 1000000, -- 30 seconds cleanupInterval = 30 * 60 * 1000000, -- 30 minutes cleanupStepInterval = 200000, -- 200ms diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index a7732b4d4..f27de3667 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -89,7 +89,7 @@ defaultSMPClientAgentConfig = increaseAfter = 10 * second, maxInterval = 10 * second }, - persistErrorInterval = 0, + persistErrorInterval = 30, -- seconds msgQSize = 256, agentQSize = 256, agentSubsBatchSize = 900, @@ -183,12 +183,13 @@ getSMPServerClient'' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, worke smpClient_ <- liftIO $ tcpConnectTimeout `timeout` atomically (readTMVar $ sessionVar v) case smpClient_ of Just (Right smpClient) -> pure smpClient - Just (Left (e, Nothing)) -> throwE e - Just (Left (e, Just ts)) -> - ifM - ((ts <) <$> liftIO getCurrentTime) - (atomically (removeSessVar v srv smpClients) >> getSMPServerClient'' ca srv) - (throwE e) + Just (Left (e, ts_)) -> case ts_ of + Nothing -> throwE e + Just ts -> + ifM + ((ts <) <$> liftIO getCurrentTime) + (atomically (removeSessVar v srv smpClients) >> getSMPServerClient'' ca srv) + (throwE e) Nothing -> throwE PCEResponseTimeout newSMPClient :: SMPClientVar -> IO (Either SMPClientError (OwnServer, SMPClient)) @@ -204,12 +205,13 @@ getSMPServerClient'' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, worke notify ca $ CAConnected srv pure $ Right c Left e -> do - if persistErrorInterval agentCfg == 0 || e == PCENetworkError || e == PCEResponseTimeout + let ei = persistErrorInterval agentCfg + if ei == 0 then atomically $ do putTMVar (sessionVar v) (Left (e, Nothing)) removeSessVar v srv smpClients else do - ts <- addUTCTime (persistErrorInterval agentCfg) <$> liftIO getCurrentTime + ts <- addUTCTime ei <$> liftIO getCurrentTime atomically $ putTMVar (sessionVar v) (Left (e, Just ts)) reconnectClient ca srv pure $ Left e diff --git a/src/Simplex/Messaging/Notifications/Server/Main.hs b/src/Simplex/Messaging/Notifications/Server/Main.hs index 0efb7d599..b28dd6111 100644 --- a/src/Simplex/Messaging/Notifications/Server/Main.hs +++ b/src/Simplex/Messaging/Notifications/Server/Main.hs @@ -13,7 +13,7 @@ import Data.Maybe (fromMaybe) import qualified Data.Text as T import Network.Socket (HostName) import Options.Applicative -import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) +import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Server (runNtfServer) import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..), defaultInactiveClientExpiration) @@ -113,7 +113,7 @@ ntfServerCLI cfgPath logPath = clientQSize = 64, subQSize = 512, pushQSize = 1048, - smpAgentCfg = defaultSMPClientAgentConfig, + smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 15}, apnsConfig = defaultAPNSPushClientConfig, subsBatchSize = 900, inactiveClientExpiration = diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index ef283a7d9..2ece3e7b9 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -236,10 +236,10 @@ runRight action = getInAnyOrder :: HasCallStack => AgentClient -> [ATransmission 'Agent -> Bool] -> Expectation getInAnyOrder c ts = withFrozenCallStack $ inAnyOrder (pGet c) ts -inAnyOrder :: (Show a, MonadIO m, HasCallStack) => m a -> [a -> Bool] -> m () +inAnyOrder :: (Show a, MonadUnliftIO m, HasCallStack) => m a -> [a -> Bool] -> m () inAnyOrder _ [] = pure () inAnyOrder g rs = withFrozenCallStack $ do - r <- g + r <- 5000000 `timeout` g >>= maybe (error "inAnyOrder timeout") pure let rest = filter (not . expected r) rs if length rest < length rs then inAnyOrder g rest @@ -1264,15 +1264,10 @@ testRatchetSyncServerOffline t = withAgentClients2 $ \alice bob -> do liftIO $ ratchetSyncState `shouldBe` RSStarted withSmpServerStoreMsgLogOn t testPort $ \_ -> do + concurrently_ + (getInAnyOrder alice [ratchetSyncP' bobId RSAgreed, serverUpP]) + (getInAnyOrder bob2 [ratchetSyncP' aliceId RSAgreed, serverUpP]) runRight_ $ do - liftIO . getInAnyOrder alice $ - [ ratchetSyncP' bobId RSAgreed, - serverUpP - ] - liftIO . getInAnyOrder bob2 $ - [ ratchetSyncP' aliceId RSAgreed, - serverUpP - ] get alice =##> ratchetSyncP bobId RSOk get bob2 =##> ratchetSyncP aliceId RSOk exchangeGreetingsMsgIds alice bobId 12 bob2 aliceId 9 @@ -1326,15 +1321,10 @@ testRatchetSyncSuspendForeground t = do foregroundAgent bob2 withSmpServerStoreMsgLogOn t testPort $ \_ -> do + concurrently_ + (getInAnyOrder alice [ratchetSyncP' bobId RSAgreed, serverUpP]) + (getInAnyOrder bob2 [ratchetSyncP' aliceId RSAgreed, serverUpP]) runRight_ $ do - liftIO . getInAnyOrder alice $ - [ ratchetSyncP' bobId RSAgreed, - serverUpP - ] - liftIO . getInAnyOrder bob2 $ - [ ratchetSyncP' aliceId RSAgreed, - serverUpP - ] get alice =##> ratchetSyncP bobId RSOk get bob2 =##> ratchetSyncP aliceId RSOk exchangeGreetingsMsgIds alice bobId 12 bob2 aliceId 9 @@ -1359,15 +1349,10 @@ testRatchetSyncSimultaneous t = do liftIO $ aRSS `shouldBe` RSStarted withSmpServerStoreMsgLogOn t testPort $ \_ -> do + concurrently_ + (getInAnyOrder alice [ratchetSyncP' bobId RSAgreed, serverUpP]) + (getInAnyOrder bob2 [ratchetSyncP' aliceId RSAgreed, serverUpP]) runRight_ $ do - liftIO . getInAnyOrder alice $ - [ ratchetSyncP' bobId RSAgreed, - serverUpP - ] - liftIO . getInAnyOrder bob2 $ - [ ratchetSyncP' aliceId RSAgreed, - serverUpP - ] get alice =##> ratchetSyncP bobId RSOk get bob2 =##> ratchetSyncP aliceId RSOk exchangeGreetingsMsgIds alice bobId 12 bob2 aliceId 9 diff --git a/tests/AgentTests/NotificationTests.hs b/tests/AgentTests/NotificationTests.hs index 04b7fc69c..348694f7e 100644 --- a/tests/AgentTests/NotificationTests.hs +++ b/tests/AgentTests/NotificationTests.hs @@ -335,6 +335,7 @@ testNtfTokenChangeServers t APNSMockServer {apnsQ} = getTestNtfTokenPort a >>= \port2 -> liftIO $ port2 `shouldBe` ntfTestPort2 -- but the token got updated killThread ntf withNtfServerOn t ntfTestPort2 $ runRight_ $ do + liftIO $ threadDelay 1000000 -- for notification server to reconnect tkn <- registerTestToken a "qwer" NMInstant apnsQ checkNtfToken a tkn >>= \r -> liftIO $ r `shouldBe` NTActive diff --git a/tests/NtfClient.hs b/tests/NtfClient.hs index 564523e0b..f5b8a4b2e 100644 --- a/tests/NtfClient.hs +++ b/tests/NtfClient.hs @@ -89,7 +89,7 @@ ntfServerCfg = clientQSize = 1, subQSize = 1, pushQSize = 1, - smpAgentCfg = defaultSMPClientAgentConfig, + smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 1}, apnsConfig = defaultAPNSPushClientConfig { apnsPort = apnsTestPort, @@ -115,9 +115,11 @@ ntfServerCfgV2 :: NtfServerConfig ntfServerCfgV2 = ntfServerCfg { ntfServerVRange = mkVersionRange initialNTFVersion authBatchCmdsNTFVersion, - smpAgentCfg = defaultSMPClientAgentConfig {smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion}}, + smpAgentCfg = smpAgentCfg' {smpCfg = (smpCfg smpAgentCfg') {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion}}, Env.transportConfig = defaultTransportServerConfig {Server.alpn = Just supportedNTFHandshakes} } + where + smpAgentCfg' = smpAgentCfg ntfServerCfg withNtfServerStoreLog :: ATransport -> (ThreadId -> IO a) -> IO a withNtfServerStoreLog t = withNtfServerCfg ntfServerCfg {storeLogFile = Just ntfTestStoreLogFile, transports = [(ntfTestPort, t)]} diff --git a/tests/SMPAgentClient.hs b/tests/SMPAgentClient.hs index 3cf09e5db..aee3c8cb0 100644 --- a/tests/SMPAgentClient.hs +++ b/tests/SMPAgentClient.hs @@ -212,6 +212,7 @@ agentCfg = smpCfg = defaultSMPClientConfig {qSize = 1, defaultTransport = (testPort, transport @TLS), networkConfig}, ntfCfg = defaultNTFClientConfig {qSize = 1, defaultTransport = (ntfTestPort, transport @TLS), networkConfig}, reconnectInterval = fastRetryInterval, + persistErrorInterval = 1, xftpNotifyErrsOnRetry = False, ntfWorkerDelay = 100, ntfSMPWorkerDelay = 100, diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 99633ac1d..1828ffae6 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -118,7 +118,7 @@ cfg = smpServerVRange = supportedServerSMPRelayVRange, transportConfig = defaultTransportServerConfig {Server.alpn = Just supportedSMPHandshakes}, controlPort = Nothing, - smpAgentCfg = defaultSMPClientAgentConfig, + smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 1}, -- seconds allowSMPProxy = False, serverClientConcurrency = 2 } @@ -134,12 +134,10 @@ proxyCfg = cfgV7 { allowSMPProxy = True, smpServerVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion, - smpAgentCfg = - defaultSMPClientAgentConfig - { smpCfg = (smpCfg defaultSMPClientAgentConfig) {serverVRange = proxyVRange, agreeSecret = True}, - persistErrorInterval = 3 -- seconds - } + smpAgentCfg = smpAgentCfg' {smpCfg = (smpCfg smpAgentCfg') {serverVRange = proxyVRange, agreeSecret = True}} } + where + smpAgentCfg' = smpAgentCfg cfgV7 proxyVRange :: VersionRangeSMP proxyVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion diff --git a/tests/XFTPAgent.hs b/tests/XFTPAgent.hs index 8f42ec16a..4685d4815 100644 --- a/tests/XFTPAgent.hs +++ b/tests/XFTPAgent.hs @@ -54,7 +54,7 @@ xftpAgentTests = around_ testBracket . describe "agent XFTP API" $ do it "should resume receiving file after restart" testXFTPAgentReceiveRestore it "should cleanup rcv tmp path after permanent error" testXFTPAgentReceiveCleanup it "should resume sending file after restart" testXFTPAgentSendRestore - it "should cleanup snd prefix path after permanent error" testXFTPAgentSendCleanup + xit "should cleanup snd prefix path after permanent error" testXFTPAgentSendCleanup it "should delete sent file on server" testXFTPAgentDelete it "should resume deleting file after restart" testXFTPAgentDeleteRestore -- TODO when server is fixed to correctly send AUTH error, this test has to be modified to expect AUTH error From 2b09ada39237baaa024447ab8d7bdccef53a0687 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 22 May 2024 14:36:54 +0100 Subject: [PATCH 076/107] agent: reduce interval for storing server connection errors to 3 seconds --- src/Simplex/Messaging/Agent/Env/SQLite.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index ccefe9c22..30b2b566a 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -169,7 +169,7 @@ defaultAgentConfig = connDeleteDeliveryTimeout = 2 * nominalDay, helloTimeout = 2 * nominalDay, quotaExceededTimeout = 7 * nominalDay, - persistErrorInterval = 10, -- seconds + persistErrorInterval = 3, -- seconds initialCleanupDelay = 30 * 1000000, -- 30 seconds cleanupInterval = 30 * 60 * 1000000, -- 30 minutes cleanupStepInterval = 200000, -- 200ms From 6c86aa302f5efc037cbc9377ae85696180cb08ab Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 22 May 2024 16:13:26 +0100 Subject: [PATCH 077/107] 5.8.0.4 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 625c30df4..be2286377 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.3 +version: 5.8.0.4 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 265df1772..e0fb278a1 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.3 +version: 5.8.0.4 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From f6bb105536103d10a4d1e8ec8ed043a1b9e1345c Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 May 2024 14:47:30 +0300 Subject: [PATCH 078/107] utils: remove MonadError usage (#1168) --- src/Simplex/Messaging/Util.hs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Messaging/Util.hs b/src/Simplex/Messaging/Util.hs index b42d5b378..ef2cc6933 100644 --- a/src/Simplex/Messaging/Util.hs +++ b/src/Simplex/Messaging/Util.hs @@ -97,15 +97,15 @@ catchAll_ :: IO a -> IO a -> IO a catchAll_ a = catchAll a . const {-# INLINE catchAll_ #-} -tryAllErrors :: (MonadUnliftIO m, MonadError e m) => (E.SomeException -> e) -> m a -> m (Either e a) -tryAllErrors err action = tryError action `UE.catch` (pure . Left . err) +tryAllErrors :: MonadUnliftIO m => (E.SomeException -> e) -> ExceptT e m a -> ExceptT e m (Either e a) +tryAllErrors err action = ExceptT $ Right <$> runExceptT action `UE.catch` (pure . Left . err) {-# INLINE tryAllErrors #-} tryAllErrors' :: MonadUnliftIO m => (E.SomeException -> e) -> ExceptT e m a -> m (Either e a) tryAllErrors' err action = runExceptT action `UE.catch` (pure . Left . err) {-# INLINE tryAllErrors' #-} -catchAllErrors :: (MonadUnliftIO m, MonadError e m) => (E.SomeException -> e) -> m a -> (e -> m a) -> m a +catchAllErrors :: MonadUnliftIO m => (E.SomeException -> e) -> ExceptT e m a -> (e -> ExceptT e m a) -> ExceptT e m a catchAllErrors err action handler = tryAllErrors err action >>= either handler pure {-# INLINE catchAllErrors #-} @@ -113,11 +113,11 @@ catchAllErrors' :: MonadUnliftIO m => (E.SomeException -> e) -> ExceptT e m a -> catchAllErrors' err action handler = tryAllErrors' err action >>= either handler pure {-# INLINE catchAllErrors' #-} -catchThrow :: (MonadUnliftIO m, MonadError e m) => m a -> (E.SomeException -> e) -> m a +catchThrow :: MonadUnliftIO m => ExceptT e m a -> (E.SomeException -> e) -> ExceptT e m a catchThrow action err = catchAllErrors err action throwError {-# INLINE catchThrow #-} -allFinally :: (MonadUnliftIO m, MonadError e m) => (E.SomeException -> e) -> m a -> m b -> m a +allFinally :: MonadUnliftIO m => (E.SomeException -> e) -> ExceptT e m a -> ExceptT e m b -> ExceptT e m a allFinally err action final = tryAllErrors err action >>= \r -> final >> either throwError pure r {-# INLINE allFinally #-} From 5d38ad03af14348febf48a4771d2c79f807fbc0f Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 May 2024 17:34:25 +0300 Subject: [PATCH 079/107] tests: add proxy stress tests (#1163) * tests: add proxy stress tests * organize benches * add agent tests * move prints to logNote * fix stuck agent tests --- tests/SMPProxyTests.hs | 172 ++++++++++++++++++++++++++++++++++------- tests/Util.hs | 22 ++++++ 2 files changed, 165 insertions(+), 29 deletions(-) diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 1c458a062..748eb34e7 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -8,13 +8,14 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} module SMPProxyTests where import AgentTests.FunctionalAPITests -import Control.Monad.Trans.Except (runExceptT) +import Control.Logger.Simple +import Control.Monad (forM, forM_, forever) +import Control.Monad.Trans.Except (ExceptT, runExceptT) import Data.ByteString.Char8 (ByteString) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L @@ -33,9 +34,12 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) import Simplex.Messaging.Transport +import Simplex.Messaging.Util (bshow, tshow) import Simplex.Messaging.Version (mkVersionRange) +import System.FilePath (splitExtensions) import Test.Hspec import UnliftIO +import Util smpProxyTests :: Spec smpProxyTests = do @@ -71,6 +75,22 @@ smpProxyTests = do deliverMessageViaProxy proxyServ relayServ C.SEd25519 msg1 msg2 it "max message size, X25519 keys" . twoServersFirstProxy $ deliverMessageViaProxy proxyServ relayServ C.SX25519 msg1 msg2 + describe "stress test 1k" $ do + let deliver n = deliverMessagesViaProxy srv1 srv2 C.SEd448 [] (map bshow [1 :: Int .. n]) + it "1x1000" . twoServersFirstProxy $ deliver 1000 + it "5x200" . twoServersFirstProxy $ 5 `inParrallel` deliver 200 + it "10x100" . twoServersFirstProxy $ 10 `inParrallel` deliver 100 + xdescribe "stress test 10k" $ do + let deliver n = deliverMessagesViaProxy srv1 srv2 C.SEd448 [] (map bshow [1 :: Int .. n]) + it "1x10000" . twoServersFirstProxy $ deliver 10000 + it "5x2000" . twoServersFirstProxy $ 5 `inParrallel` deliver 2000 + it "10x1000" . twoServersFirstProxy $ 10 `inParrallel` deliver 1000 + it "100x100 N1" . twoServersFirstProxy $ withNumCapabilities 1 $ 100 `inParrallel` deliver 100 + it "100x100 N4 C1" . twoServersNoConc $ withNumCapabilities 4 $ 100 `inParrallel` deliver 100 + it "100x100 N4 C2" . twoServersFirstProxy $ withNumCapabilities 4 $ 100 `inParrallel` deliver 100 + it "100x100 N4 C16" . twoServersMoreConc $ withNumCapabilities 4 $ 100 `inParrallel` deliver 100 + it "100x100 N" . twoServersFirstProxy $ withNCPUCapabilities $ 100 `inParrallel` deliver 100 + it "500x20" . twoServersFirstProxy $ 500 `inParrallel` deliver 20 describe "agent API" $ do describe "one server" $ do it "always via proxy" . oneServer $ @@ -92,46 +112,65 @@ smpProxyTests = do agentDeliverMessageViaProxy ([srv1], SPMUnknown, False) ([srv2], SPMUnknown, False) C.SEd448 "hello 1" "hello 2" it "fails when fallback is prohibited" . twoServers_ proxyCfg cfgV7 $ agentViaProxyVersionError + describe "stress test 1k" $ do + let deliver nAgents nMsgs = agentDeliverMessagesViaProxyConc (replicate nAgents [srv1]) (map bshow [1 :: Int .. nMsgs]) + it "2 agents, 250 messages" . oneServer $ deliver 2 250 + it "5 agents, 10 pairs, 50 messages, N1" . oneServer . withNumCapabilities 1 $ deliver 5 50 + it "5 agents, 10 pairs, 50 messages. N4" . oneServer . withNumCapabilities 4 $ deliver 5 50 + xdescribe "stress test 10k" $ do + let deliver nAgents nMsgs = agentDeliverMessagesViaProxyConc (replicate nAgents [srv1]) (map bshow [1 :: Int .. nMsgs]) + it "25 agents, 300 pairs, 17 messages" . oneServer . withNumCapabilities 4 $ deliver 25 17 where - oneServer = withSmpServerConfigOn (transport @TLS) proxyCfg testPort . const + oneServer = withSmpServerConfigOn (transport @TLS) proxyCfg {msgQueueQuota = 128} testPort . const twoServers = twoServers_ proxyCfg proxyCfg - twoServersFirstProxy = twoServers_ proxyCfg cfgV8 + twoServersFirstProxy = twoServers_ proxyCfg cfgV8 {msgQueueQuota = 128} + twoServersMoreConc = twoServers_ proxyCfg {serverClientConcurrency = 128} cfgV8 {msgQueueQuota = 128} + twoServersNoConc = twoServers_ proxyCfg {serverClientConcurrency = 1} cfgV8 {msgQueueQuota = 128} twoServers_ cfg1 cfg2 runTest = withSmpServerConfigOn (transport @TLS) cfg1 testPort $ \_ -> withSmpServerConfigOn (transport @TLS) cfg2 testPort2 $ const runTest deliverMessageViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => SMPServer -> SMPServer -> C.SAlgorithm a -> ByteString -> ByteString -> IO () -deliverMessageViaProxy proxyServ relayServ alg msg msg' = do +deliverMessageViaProxy proxyServ relayServ alg msg msg' = deliverMessagesViaProxy proxyServ relayServ alg [msg] [msg'] + +deliverMessagesViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => SMPServer -> SMPServer -> C.SAlgorithm a -> [ByteString] -> [ByteString] -> IO () +deliverMessagesViaProxy proxyServ relayServ alg unsecuredMsgs securedMsgs = do g <- C.newRandom -- set up proxy - Right pc <- getProtocolClient g (1, proxyServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} Nothing (\_ -> pure ()) + pc' <- getProtocolClient g (1, proxyServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} Nothing (\_ -> pure ()) + pc <- either (fail . show) pure pc' THAuthClient {} <- maybe (fail "getProtocolClient returned no thAuth") pure $ thAuth $ thParams pc -- set up relay - msgQ <- newTBQueueIO 4 - Right rc <- getProtocolClient g (2, relayServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} (Just msgQ) (\_ -> pure ()) - runRight_ $ do - -- prepare receiving queue - (rPub, rPriv) <- atomically $ C.generateAuthKeyPair alg g - (rdhPub, rdhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g - QIK {rcvId, sndId, rcvPublicDhKey = srvDh} <- createSMPQueue rc (rPub, rPriv) rdhPub (Just "correct") SMSubscribe - let dec = decryptMsgV3 $ C.dh' srvDh rdhPriv - -- get proxy session - sess <- connectSMPProxiedRelay pc relayServ (Just "correct") - -- send via proxy to unsecured queue - Right () <- proxySMPMessage pc sess Nothing sndId noMsgFlags msg + msgQ <- newTBQueueIO 1024 + rc' <- getProtocolClient g (2, relayServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange batchCmdsSMPVersion authCmdsSMPVersion} (Just msgQ) (\_ -> pure ()) + rc <- either (fail . show) pure rc' + -- prepare receiving queue + (rPub, rPriv) <- atomically $ C.generateAuthKeyPair alg g + (rdhPub, rdhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g + QIK {rcvId, sndId, rcvPublicDhKey = srvDh} <- runExceptT' $ createSMPQueue rc (rPub, rPriv) rdhPub (Just "correct") SMSubscribe + let dec = decryptMsgV3 $ C.dh' srvDh rdhPriv + -- get proxy session + sess <- runExceptT' $ connectSMPProxiedRelay pc relayServ (Just "correct") + -- send via proxy to unsecured queue + forM_ unsecuredMsgs $ \msg -> do + runExceptT' (proxySMPMessage pc sess Nothing sndId noMsgFlags msg) `shouldReturn` Right () -- receive 1 (_tSess, _v, _sid, [(_entId, STEvent (Right (SMP.MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody})))]) <- atomically $ readTBQueue msgQ - liftIO $ dec msgId encBody `shouldBe` Right msg - ackSMPMessage rc rPriv rcvId msgId - -- secure queue - (sPub, sPriv) <- atomically $ C.generateAuthKeyPair alg g - secureSMPQueue rc rPriv rcvId sPub - -- send via proxy to secured queue - Right () <- proxySMPMessage pc sess (Just sPriv) sndId noMsgFlags msg' - -- receive 2 - (_tSess, _v, _sid, [(_entId, STEvent (Right (SMP.MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'})))]) <- atomically $ readTBQueue msgQ - liftIO $ dec msgId' encBody' `shouldBe` Right msg' - ackSMPMessage rc rPriv rcvId msgId' + dec msgId encBody `shouldBe` Right msg + runExceptT' $ ackSMPMessage rc rPriv rcvId msgId + -- secure queue + (sPub, sPriv) <- atomically $ C.generateAuthKeyPair alg g + runExceptT' $ secureSMPQueue rc rPriv rcvId sPub + -- send via proxy to secured queue + waitSendRecv + ( forM_ securedMsgs $ \msg' -> + runExceptT' (proxySMPMessage pc sess (Just sPriv) sndId noMsgFlags msg') `shouldReturn` Right () + ) + ( forM_ securedMsgs $ \msg' -> do + (_tSess, _v, _sid, [(_entId, STEvent (Right (SMP.MSG RcvMessage {msgId = msgId', msgBody = EncRcvMsgBody encBody'})))]) <- atomically $ readTBQueue msgQ + dec msgId' encBody' `shouldBe` Right msg' + runExceptT' $ ackSMPMessage rc rPriv rcvId msgId' + ) agentDeliverMessageViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => (NonEmpty SMPServer, SMPProxyMode, Bool) -> (NonEmpty SMPServer, SMPProxyMode, Bool) -> C.SAlgorithm a -> ByteString -> ByteString -> IO () agentDeliverMessageViaProxy aTestCfg@(aSrvs, _, aViaProxy) bTestCfg@(bSrvs, _, bViaProxy) alg msg1 msg2 = @@ -171,6 +210,71 @@ agentDeliverMessageViaProxy aTestCfg@(aSrvs, _, aViaProxy) bTestCfg@(bSrvs, _, b aCfg = agentProxyCfg {sndAuthAlg = C.AuthAlg alg, rcvAuthAlg = C.AuthAlg alg} servers (srvs, smpProxyMode, _) = (initAgentServersProxy smpProxyMode SPFAllow) {smp = userServers $ L.map noAuthSrv srvs} +agentDeliverMessagesViaProxyConc :: [NonEmpty SMPServer] -> [MsgBody] -> IO () +agentDeliverMessagesViaProxyConc agentServers msgs = + withAgents $ \agents -> do + let pairs = combinations 2 agents + logNote $ "Pairing " <> tshow (length agents) <> " agents into " <> tshow (length pairs) <> " connections" + connections <- forM pairs $ \case + [a, b] -> prePair a b + _ -> error "agents must be paired" + logNote "Running..." + mapConcurrently_ run connections + where + withAgents :: ([AgentClient] -> IO ()) -> IO () + withAgents action = go [] (zip [1 :: Int ..] agentServers) + where + go agents = \case + [] -> action agents + (aId, aSrvs) : next -> withAgent aId aCfg (servers aSrvs) (dbPrefix <> show aId <> dbSuffix) $ \a -> (a : agents) `go` next + (dbPrefix, dbSuffix) = splitExtensions testDB + -- agent connections have to be set up in advance + -- otherwise the CONF messages would get mixed with MSG + prePair alice bob = do + (bobId, qInfo) <- runExceptT' $ A.createConnection alice 1 True SCMInvitation Nothing (CR.IKNoPQ PQSupportOn) SMSubscribe + aliceId <- runExceptT' $ A.joinConnection bob 1 Nothing True qInfo "bob's connInfo" PQSupportOn SMSubscribe + confId <- + get alice >>= \case + ("", _, A.CONF confId pqSup' _ "bob's connInfo") -> do + pqSup' `shouldBe` PQSupportOn + pure confId + huh -> fail $ show huh + runExceptT' $ allowConnection alice bobId confId "alice's connInfo" + get alice ##> ("", bobId, A.CON pqEnc) + get bob ##> ("", aliceId, A.INFO PQSupportOn "alice's connInfo") + get bob ##> ("", aliceId, A.CON pqEnc) + pure (alice, bobId, bob, aliceId) + -- stream messages in opposite directions, while getting deliveries and sending ACKs + run (alice, bobId, bob, aliceId) = do + aSender <- async $ forM_ msgs $ runExceptT' . A.sendMessage alice bobId pqEnc noMsgFlags + bRecipient <- + async $ + forever $ + get bob >>= \case + ("", _, A.SENT _ _) -> pure () + ("", _, Msg' mId' _ _) -> runExceptT' $ ackMessage alice bobId mId' Nothing + huh -> fail (show huh) + bSender <- async $ forM_ msgs $ runExceptT' . A.sendMessage bob aliceId pqEnc noMsgFlags + aRecipient <- + async $ + forever $ + get alice >>= \case + ("", _, A.SENT _ _) -> pure () + ("", _, Msg' mId' _ _) -> runExceptT' $ ackMessage alice bobId mId' Nothing + huh -> fail (show huh) + logDebug "run waiting..." + a2b <- async $ (waitCatch aSender >>= either throwIO pure) `finally` cancel bRecipient -- stopped sender cancels paired recipient loop + b2a <- async $ (waitCatch bSender >>= either throwIO pure) `finally` cancel aRecipient + waitEitherCatch a2b b2a >>= \case + Right (Right ()) -> wait b2a + Right (Left e) -> cancel bSender >> throwIO e + Left (Right ()) -> wait a2b + Left (Left e) -> cancel aSender >> throwIO e + logDebug "run finished" + pqEnc = CR.PQEncOn + aCfg = agentProxyCfg {sndAuthAlg = C.AuthAlg C.SEd448, rcvAuthAlg = C.AuthAlg C.SEd448} + servers srvs = (initAgentServersProxy SPMAlways SPFAllow) {smp = userServers $ L.map noAuthSrv srvs} + agentViaProxyVersionError :: IO () agentViaProxyVersionError = withAgent 1 agentProxyCfg (servers [SMPServer testHost testPort testKeyHash]) testDB $ \alice -> do @@ -201,3 +305,13 @@ testProxyAuth = do todo :: IO () todo = do fail "TODO" + +runExceptT' :: Exception e => ExceptT e IO a -> IO a +runExceptT' a = runExceptT a >>= either throwIO pure + +waitSendRecv :: IO () -> IO () -> IO () +waitSendRecv s r = do + s' <- async s + r' <- async r + waitCatch s' >>= either (\e -> cancel r' >> fail (show e)) pure + waitCatch r' >>= either (\e -> cancel s' >> fail (show e)) pure diff --git a/tests/Util.hs b/tests/Util.hs index a52fee32c..6ad6d054f 100644 --- a/tests/Util.hs +++ b/tests/Util.hs @@ -1,6 +1,28 @@ module Util where +import Control.Monad (replicateM) +import Data.Either (partitionEithers) +import Data.List (tails) +import GHC.Conc (getNumCapabilities, getNumProcessors, setNumCapabilities) import Test.Hspec +import UnliftIO skip :: String -> SpecWith a -> SpecWith a skip = before_ . pendingWith + +withNumCapabilities :: Int -> IO a -> IO a +withNumCapabilities new a = getNumCapabilities >>= \old -> bracket_ (setNumCapabilities new) (setNumCapabilities old) a + +withNCPUCapabilities :: IO a -> IO a +withNCPUCapabilities a = getNumProcessors >>= \p -> withNumCapabilities p a + +inParrallel :: Int -> IO () -> IO () +inParrallel n action = do + streams <- replicateM n $ async action + (es, rs) <- partitionEithers <$> mapM waitCatch streams + map show es `shouldBe` [] + length rs `shouldBe` n + +combinations :: Int -> [a] -> [[a]] +combinations 0 _ = [[]] +combinations k xs = [y : ys | y : xs' <- tails xs, ys <- combinations (k - 1) xs'] From 984394d90630731dcbc26ba0ffa13bf9fa98a251 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Thu, 23 May 2024 18:44:00 +0300 Subject: [PATCH 080/107] core: remove MonadUnliftIO ExceptT orphans (#1169) --- src/Simplex/FileTransfer/Client.hs | 1 - src/Simplex/FileTransfer/Client/Agent.hs | 1 - src/Simplex/Messaging/Agent/Client.hs | 1 - src/Simplex/Messaging/Agent/Env/SQLite.hs | 1 - .../Messaging/Agent/NtfSubSupervisor.hs | 1 - src/Simplex/Messaging/Client/Agent.hs | 29 ------------------- src/Simplex/Messaging/Crypto/File.hs | 1 - tests/AgentTests/FunctionalAPITests.hs | 18 +++++++++++- tests/CoreTests/UtilTests.hs | 2 +- 9 files changed, 18 insertions(+), 37 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 468df6157..e407279ce 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -39,7 +39,6 @@ import Simplex.Messaging.Client proxyUsername, transportClientConfig, ) -import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.Messaging.Encoding (smpDecode, smpEncode) diff --git a/src/Simplex/FileTransfer/Client/Agent.hs b/src/Simplex/FileTransfer/Client/Agent.hs index 1dafc8108..c17790c2d 100644 --- a/src/Simplex/FileTransfer/Client/Agent.hs +++ b/src/Simplex/FileTransfer/Client/Agent.hs @@ -18,7 +18,6 @@ import Data.Text.Encoding (decodeUtf8) import Simplex.FileTransfer.Client import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Client (NetworkConfig (..), ProtocolClientError (..), temporaryClientError) -import Simplex.Messaging.Client.Agent () import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ProtocolServer (..), XFTPServer) import Simplex.Messaging.TMap (TMap) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 30e1f0aa8..e59dee7a1 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -185,7 +185,6 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Agent.TRcvQueues (TRcvQueues (getRcvQueues)) import qualified Simplex.Messaging.Agent.TRcvQueues as RQ import Simplex.Messaging.Client -import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String diff --git a/src/Simplex/Messaging/Agent/Env/SQLite.hs b/src/Simplex/Messaging/Agent/Env/SQLite.hs index b753a4226..cd32d4e8a 100644 --- a/src/Simplex/Messaging/Agent/Env/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Env/SQLite.hs @@ -54,7 +54,6 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Client -import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (VersionRangeE2E, supportedE2EEncryptVRange) import Simplex.Messaging.Notifications.Client (defaultNTFClientConfig) diff --git a/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs b/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs index 4aaa5f278..1e2c7cb00 100644 --- a/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs +++ b/src/Simplex/Messaging/Agent/NtfSubSupervisor.hs @@ -33,7 +33,6 @@ import Simplex.Messaging.Agent.Protocol (ACommand (..), APartyCmd (..), AgentErr import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store import Simplex.Messaging.Agent.Store.SQLite -import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol (NtfSubStatus (..), NtfTknStatus (..), SMPQueueNtf (..)) import Simplex.Messaging.Notifications.Types diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 4b925c6f6..2ade2d24f 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -8,7 +8,6 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} -{-# OPTIONS_GHC -fno-warn-orphans #-} module Simplex.Messaging.Client.Agent where @@ -19,7 +18,6 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Trans.Except -import Control.Monad.Trans.Reader import Crypto.Random (ChaChaDRG) import Data.Bifunctor (bimap, first) import Data.ByteString.Char8 (ByteString) @@ -47,8 +45,6 @@ import Simplex.Messaging.Transport import Simplex.Messaging.Util (catchAll_, toChunks, ($>>=)) import System.Timeout (timeout) import UnliftIO (async) -import UnliftIO.Exception (Exception) -import qualified UnliftIO.Exception as E import UnliftIO.STM type SMPClientVar = SessionVar (Either SMPClientError SMPClient) @@ -105,31 +101,6 @@ data SMPClientAgent = SMPClientAgent workerSeq :: TVar Int } -newtype InternalException e = InternalException {unInternalException :: e} - deriving (Eq, Show) - -instance Exception e => Exception (InternalException e) - -instance Exception e => MonadUnliftIO (ExceptT e IO) where - {-# INLINE withRunInIO #-} - withRunInIO :: ((forall a. ExceptT e IO a -> IO a) -> IO b) -> ExceptT e IO b - withRunInIO inner = - ExceptT . fmap (first unInternalException) . E.try $ - withRunInIO $ \run -> - inner $ run . (either (E.throwIO . InternalException) pure <=< runExceptT) - --- as MonadUnliftIO instance for IO is `withRunInIO inner = inner id`, --- the last two lines could be replaced with: --- inner $ either (E.throwIO . InternalException) pure <=< runExceptT - -instance Exception e => MonadUnliftIO (ExceptT e (ReaderT r IO)) where - {-# INLINE withRunInIO #-} - withRunInIO :: ((forall a. ExceptT e (ReaderT r IO) a -> IO a) -> IO b) -> ExceptT e (ReaderT r IO) b - withRunInIO inner = - withExceptT unInternalException . ExceptT . E.try $ - withRunInIO $ \run -> - inner $ run . (either (E.throwIO . InternalException) pure <=< runExceptT) - newSMPClientAgent :: SMPClientAgentConfig -> TVar ChaChaDRG -> STM SMPClientAgent newSMPClientAgent agentCfg@SMPClientAgentConfig {msgQSize, agentQSize} randomDrg = do msgQ <- newTBQueue msgQSize diff --git a/src/Simplex/Messaging/Crypto/File.hs b/src/Simplex/Messaging/Crypto/File.hs index 2787df58e..9608d21b7 100644 --- a/src/Simplex/Messaging/Crypto/File.hs +++ b/src/Simplex/Messaging/Crypto/File.hs @@ -31,7 +31,6 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.List.NonEmpty (NonEmpty (..)) import Data.Maybe (isJust) -import Simplex.Messaging.Client.Agent () import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Lazy (LazyByteString) import qualified Simplex.Messaging.Crypto.Lazy as LC diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index bb1d34b10..72a62cbbf 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -1,7 +1,9 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE InstanceSigs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} @@ -54,6 +56,7 @@ import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Monad import Control.Monad.Except import Control.Monad.Reader +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isRight) @@ -65,7 +68,7 @@ import Data.Maybe (isJust, isNothing) import qualified Data.Set as S import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.Clock.System (SystemTime (..), getSystemTime) -import Data.Type.Equality +import Data.Type.Equality (testEquality, (:~:) (Refl)) import Data.Word (Word16) import qualified Database.SQLite.Simple as SQL import GHC.Stack (withFrozenCallStack) @@ -2809,3 +2812,16 @@ exchangeGreetingsMsgIds alice bobId aliceMsgId bob aliceId bobMsgId = do get bob ##> ("", aliceId, SENT bobMsgId') get alice =##> \case ("", c, Msg "hello too") -> c == bobId; _ -> False ackMessage alice bobId aliceMsgId' Nothing + +newtype InternalException e = InternalException {unInternalException :: e} + deriving (Eq, Show) + +instance Exception e => Exception (InternalException e) + +instance Exception e => MonadUnliftIO (ExceptT e IO) where + {-# INLINE withRunInIO #-} + withRunInIO :: ((forall a. ExceptT e IO a -> IO a) -> IO b) -> ExceptT e IO b + withRunInIO inner = + ExceptT . fmap (first unInternalException) . try $ + withRunInIO $ \run -> + inner $ run . (either (throwIO . InternalException) pure <=< runExceptT) diff --git a/tests/CoreTests/UtilTests.hs b/tests/CoreTests/UtilTests.hs index 9e413e838..2254ecafd 100644 --- a/tests/CoreTests/UtilTests.hs +++ b/tests/CoreTests/UtilTests.hs @@ -2,11 +2,11 @@ module CoreTests.UtilTests where +import AgentTests.FunctionalAPITests () import Control.Exception (Exception, SomeException, throwIO) import Control.Monad.Except import Control.Monad.IO.Class import Data.IORef -import Simplex.Messaging.Client.Agent () import Simplex.Messaging.Util import Test.Hspec import qualified UnliftIO.Exception as UE From 6309f92c6860fce39d6675eb92fc460bdc3db01d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 23 May 2024 22:01:57 +0100 Subject: [PATCH 081/107] agent: fail if non-unique connection IDs are passed to sendMessages (#1170) --- src/Simplex/Messaging/Agent.hs | 30 +++++++++++++++++++-------- src/Simplex/Messaging/Agent/Client.hs | 6 +++--- src/Simplex/Messaging/Agent/Lock.hs | 10 ++++----- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 652e2d58d..dddaa7231 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -138,6 +138,8 @@ import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) +import Data.Set (Set) +import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock @@ -354,12 +356,12 @@ sendMessage c = withAgentEnv c .:: sendMessage' c type MsgReq = (ConnId, PQEncryption, MsgFlags, MsgBody) -- | Send multiple messages to different connections (SEND command) -sendMessages :: AgentClient -> [MsgReq] -> IO [Either AgentErrorType (AgentMsgId, PQEncryption)] -sendMessages c = withAgentEnv' c . sendMessages' c +sendMessages :: AgentClient -> [MsgReq] -> AE [Either AgentErrorType (AgentMsgId, PQEncryption)] +sendMessages c = withAgentEnv c . sendMessages' c {-# INLINE sendMessages #-} -sendMessagesB :: Traversable t => AgentClient -> t (Either AgentErrorType MsgReq) -> IO (t (Either AgentErrorType (AgentMsgId, PQEncryption))) -sendMessagesB c = withAgentEnv' c . sendMessagesB' c +sendMessagesB :: Traversable t => AgentClient -> t (Either AgentErrorType MsgReq) -> AE (t (Either AgentErrorType (AgentMsgId, PQEncryption))) +sendMessagesB c = withAgentEnv c . sendMessagesB' c {-# INLINE sendMessagesB #-} ackMessage :: AgentClient -> ConnId -> AgentMsgId -> Maybe MsgReceiptInfo -> AE () @@ -1033,16 +1035,27 @@ getNotificationMessage' c nonce encNtfInfo = do -- | Send message to the connection (SEND command) in Reader monad sendMessage' :: AgentClient -> ConnId -> PQEncryption -> MsgFlags -> MsgBody -> AM (AgentMsgId, PQEncryption) -sendMessage' c connId pqEnc msgFlags msg = ExceptT $ runIdentity <$> sendMessagesB' c (Identity (Right (connId, pqEnc, msgFlags, msg))) +sendMessage' c connId pqEnc msgFlags msg = ExceptT $ runIdentity <$> sendMessagesB_ c (Identity (Right (connId, pqEnc, msgFlags, msg))) (S.singleton connId) {-# INLINE sendMessage' #-} -- | Send multiple messages to different connections (SEND command) in Reader monad -sendMessages' :: AgentClient -> [MsgReq] -> AM' [Either AgentErrorType (AgentMsgId, PQEncryption)] +sendMessages' :: AgentClient -> [MsgReq] -> AM [Either AgentErrorType (AgentMsgId, PQEncryption)] sendMessages' c = sendMessagesB' c . map Right {-# INLINE sendMessages' #-} -sendMessagesB' :: forall t. Traversable t => AgentClient -> t (Either AgentErrorType MsgReq) -> AM' (t (Either AgentErrorType (AgentMsgId, PQEncryption))) -sendMessagesB' c reqs = withConnLocks c connIds "sendMessages" $ do +sendMessagesB' :: forall t. Traversable t => AgentClient -> t (Either AgentErrorType MsgReq) -> AM (t (Either AgentErrorType (AgentMsgId, PQEncryption))) +sendMessagesB' c reqs = do + connIds <- liftEither $ foldl' addConnId (Right S.empty) reqs + lift $ sendMessagesB_ c reqs connIds + where + addConnId s@(Right s') (Right (connId, _, _, _)) + | B.null connId = s + | connId `S.notMember` s' = Right $ S.insert connId s' + | otherwise = Left $ INTERNAL "sendMessages: duplicate connection ID" + addConnId s _ = s + +sendMessagesB_ :: forall t. Traversable t => AgentClient -> t (Either AgentErrorType MsgReq) -> Set ConnId -> AM' (t (Either AgentErrorType (AgentMsgId, PQEncryption))) +sendMessagesB_ c reqs connIds = withConnLocks c connIds "sendMessages" $ do reqs' <- withStoreBatch c (\db -> fmap (bindRight $ \req@(connId, _, _, _) -> bimap storeError (req,) <$> getConn db connId) reqs) let (toEnable, reqs'') = mapAccumL prepareConn [] reqs' void $ withStoreBatch' c $ \db -> map (\connId -> setConnPQSupport db connId PQSupportOn) toEnable @@ -1064,7 +1077,6 @@ sendMessagesB' c reqs = withConnLocks c connIds "sendMessages" $ do let cData' = cData {pqSupport = PQSupportOn} :: ConnData in (connId : acc, Right (cData', sqs, Just pqEnc, msgFlags, A_MSG msg)) | otherwise = (acc, Right (cData, sqs, Just pqEnc, msgFlags, A_MSG msg)) - connIds = map (\(connId, _, _, _) -> connId) $ rights $ toList reqs -- / async command processing v v v diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index e59dee7a1..2dd4ec3c9 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -826,15 +826,15 @@ withInvLock' :: AgentClient -> ByteString -> String -> AM' a -> AM' a withInvLock' AgentClient {invLocks} = withLockMap invLocks {-# INLINE withInvLock' #-} -withConnLocks :: AgentClient -> [ConnId] -> String -> AM' a -> AM' a -withConnLocks AgentClient {connLocks} = withLocksMap_ connLocks . filter (not . B.null) +withConnLocks :: AgentClient -> Set ConnId -> String -> AM' a -> AM' a +withConnLocks AgentClient {connLocks} = withLocksMap_ connLocks {-# INLINE withConnLocks #-} withLockMap :: (Ord k, MonadUnliftIO m) => TMap k Lock -> k -> String -> m a -> m a withLockMap = withGetLock . getMapLock {-# INLINE withLockMap #-} -withLocksMap_ :: (Ord k, MonadUnliftIO m) => TMap k Lock -> [k] -> String -> m a -> m a +withLocksMap_ :: (Ord k, MonadUnliftIO m) => TMap k Lock -> Set k -> String -> m a -> m a withLocksMap_ = withGetLocks . getMapLock {-# INLINE withLocksMap_ #-} diff --git a/src/Simplex/Messaging/Agent/Lock.hs b/src/Simplex/Messaging/Agent/Lock.hs index c0647b844..69b8169e2 100644 --- a/src/Simplex/Messaging/Agent/Lock.hs +++ b/src/Simplex/Messaging/Agent/Lock.hs @@ -12,6 +12,8 @@ import Control.Monad (void) import Control.Monad.Except (ExceptT (..), runExceptT) import Control.Monad.IO.Unlift import Data.Functor (($>)) +import Data.Set (Set) +import qualified Data.Set as S import UnliftIO.Async (forConcurrently) import qualified UnliftIO.Exception as E import UnliftIO.STM @@ -39,13 +41,11 @@ withGetLock getLock key name a = (atomically . takeTMVar) (const a) -withGetLocks :: MonadUnliftIO m => (k -> STM Lock) -> [k] -> String -> m a -> m a +withGetLocks :: MonadUnliftIO m => (k -> STM Lock) -> Set k -> String -> m a -> m a withGetLocks getLock keys name = E.bracket holdLocks releaseLocks . const where - holdLocks = forConcurrently keys $ \key -> atomically $ getPutLock getLock key name - -- only this withGetLocks would be holding the locks, - -- so it's safe to combine all lock releases into one transaction - releaseLocks = atomically . mapM_ takeTMVar + holdLocks = forConcurrently (S.toList keys) $ \key -> atomically $ getPutLock getLock key name + releaseLocks = mapM_ (atomically . takeTMVar) -- getLock and putTMVar can be in one transaction on the assumption that getLock doesn't write in case the lock already exists, -- and in case it is created and added to some shared resource (we use TMap) it also helps avoid contention for the newly created lock. From 2ff5f5a832d092ee453fcbdbb73360cf2f574368 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 24 May 2024 14:13:01 +0100 Subject: [PATCH 082/107] agent: add context to CMD error (#1167) * agent: add context to CMD error * tests, more warnings * fix tests * log TBQueue sizes * log locks * more logs * log sendMessagesB * fix test * log length * refactor * remove logging * revert lock scope change * cleanup * add string error to A_PROHIBITED * remove * remove test limitations * language --- package.yaml | 22 +++- simplexmq.cabal | 14 +-- src/Simplex/FileTransfer/Client.hs | 2 +- src/Simplex/FileTransfer/Client/Main.hs | 6 +- src/Simplex/FileTransfer/Server.hs | 2 +- src/Simplex/Messaging/Agent.hs | 117 +++++++++--------- src/Simplex/Messaging/Agent/Client.hs | 8 +- src/Simplex/Messaging/Agent/Protocol.hs | 32 ++--- src/Simplex/Messaging/Agent/Store/SQLite.hs | 2 +- src/Simplex/Messaging/Crypto/Ratchet.hs | 4 +- .../Messaging/Transport/HTTP2/Client.hs | 2 +- src/Simplex/Messaging/Transport/HTTP2/File.hs | 2 +- src/Simplex/Messaging/Transport/Server.hs | 2 +- tests/AgentTests.hs | 13 +- tests/AgentTests/FunctionalAPITests.hs | 11 +- tests/AgentTests/NotificationTests.hs | 5 +- 16 files changed, 131 insertions(+), 113 deletions(-) diff --git a/package.yaml b/package.yaml index be2286377..8cd924b4e 100644 --- a/package.yaml +++ b/package.yaml @@ -177,13 +177,31 @@ tests: ghc-options: # - -haddock - - -Wall + - -Weverything + - -Wno-missing-exported-signatures + - -Wno-missing-import-lists + - -Wno-missed-specialisations + - -Wno-all-missed-specialisations + - -Wno-unsafe + - -Wno-safe + - -Wno-missing-local-signatures + - -Wno-missing-kind-signatures + - -Wno-missing-deriving-strategies + - -Wno-monomorphism-restriction + - -Wno-prepositive-qualified-module + - -Wno-unused-packages + - -Wno-implicit-prelude + - -Wno-missing-safe-haskell-mode + - -Wno-missing-export-lists + - -Wno-partial-fields - -Wcompat + - -Werror=incomplete-record-updates - -Werror=incomplete-patterns + - -Werror=incomplete-uni-patterns - -Werror=missing-methods + - -Werror=tabs - -Wredundant-constraints - -Wincomplete-record-updates - - -Wincomplete-uni-patterns - -Wunused-type-patterns - -O2 diff --git a/simplexmq.cabal b/simplexmq.cabal index e0fb278a1..c61e68624 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -175,7 +175,7 @@ library src default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 include-dirs: cbits c-sources: @@ -256,7 +256,7 @@ executable ntf-server apps/ntf-server default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -331,7 +331,7 @@ executable smp-agent apps/smp-agent default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -406,7 +406,7 @@ executable smp-server apps/smp-server default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -481,7 +481,7 @@ executable xftp apps/xftp default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -556,7 +556,7 @@ executable xftp-server apps/xftp-server default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -664,7 +664,7 @@ test-suite simplexmq-test tests default-extensions: StrictData - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Werror=missing-methods -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -O2 -threaded -rtsopts -with-rtsopts=-A64M -with-rtsopts=-N1 + ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts -with-rtsopts=-A64M -with-rtsopts=-N1 build-depends: HUnit ==1.6.* , QuickCheck ==2.14.* diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index e4a64c0c9..ff7742e67 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -216,7 +216,7 @@ sendXFTPTransmission XFTPClient {config, thParams, http2Client} t chunkSpec_ = d forM_ chunkSpec_ $ \XFTPChunkSpec {filePath, chunkOffset, chunkSize} -> withFile filePath ReadMode $ \h -> do hSeek h AbsoluteSeek $ fromIntegral chunkOffset - hSendFile h send $ fromIntegral chunkSize + hSendFile h send chunkSize done createXFTPChunk :: diff --git a/src/Simplex/FileTransfer/Client/Main.hs b/src/Simplex/FileTransfer/Client/Main.hs index b3fa494ed..0acc6d3c9 100644 --- a/src/Simplex/FileTransfer/Client/Main.hs +++ b/src/Simplex/FileTransfer/Client/Main.hs @@ -332,7 +332,7 @@ cliSendFileOpts SendOptions {filePath, outputDir, numRecipients, xftpServers, re (sndKey, spKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g rKeys <- atomically $ L.fromList <$> replicateM numRecipients (C.generateAuthKeyPair C.SEd25519 g) digest <- liftIO $ getChunkDigest chunkSpec - let ch = FileInfo {sndKey, size = fromIntegral chunkSize, digest} + let ch = FileInfo {sndKey, size = chunkSize, digest} c <- withRetry retryCount $ getXFTPServerClient a xftpServer (sndId, rIds) <- withRetry retryCount $ createXFTPChunk c spKey ch (L.map fst rKeys) auth withReconnect a xftpServer retryCount $ \c' -> uploadXFTPChunk c' spKey sndId chunkSpec @@ -344,7 +344,7 @@ cliSendFileOpts SendOptions {filePath, outputDir, numRecipients, xftpServers, re when verbose $ putStrLn "" let recipients = L.toList $ L.map ChunkReplicaId rIds `L.zip` L.map snd rKeys replicas = [SentFileChunkReplica {server = xftpServer, recipients}] - pure (chunkNo, SentFileChunk {chunkNo, sndId, sndPrivateKey = spKey, chunkSize = FileSize $ fromIntegral chunkSize, digest = FileDigest digest, replicas}) + pure (chunkNo, SentFileChunk {chunkNo, sndId, sndPrivateKey = spKey, chunkSize = FileSize chunkSize, digest = FileDigest digest, replicas}) getXFTPServer :: TVar StdGen -> NonEmpty XFTPServerWithAuth -> IO XFTPServerWithAuth getXFTPServer gen = \case srv :| [] -> pure srv @@ -563,7 +563,7 @@ prepareChunkSpecs filePath chunkSizes = reverse . snd $ foldl' addSpec (0, []) c where addSpec :: (Int64, [XFTPChunkSpec]) -> Word32 -> (Int64, [XFTPChunkSpec]) addSpec (chunkOffset, specs) sz = - let spec = XFTPChunkSpec {filePath, chunkOffset, chunkSize = fromIntegral sz} + let spec = XFTPChunkSpec {filePath, chunkOffset, chunkSize = sz} in (chunkOffset + fromIntegral sz, spec : specs) getEncPath :: MonadIO m => Maybe FilePath -> String -> m FilePath diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index 41c652ea2..ea18b4fdc 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -326,7 +326,7 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea send $ byteString t -- timeout sending file in the same way as receiving forM_ serverFile_ $ \ServerFile {filePath, fileSize, sbState} -> do - withFile filePath ReadMode $ \h -> sendEncFile h send sbState (fromIntegral fileSize) + withFile filePath ReadMode $ \h -> sendEncFile h send sbState fileSize done data VerificationResult = VRVerified XFTPRequest | VRFailed diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 1ca92b2e1..bb6e52bad 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -120,10 +120,11 @@ module Simplex.Messaging.Agent ) where -import Control.Logger.Simple (logError, logInfo, showText) +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.Reader +import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import Data.Bifunctor (bimap, first, second) @@ -571,7 +572,7 @@ withAgentEnv c a = ExceptT $ runExceptT a `runReaderT` agentEnv c logConnection :: AgentClient -> Bool -> IO () logConnection c connected = let event = if connected then "connected to" else "disconnected from" - in logInfo $ T.unwords ["client", showText (clientId c), event, "Agent"] + in logInfo $ T.unwords ["client", tshow (clientId c), event, "Agent"] -- | Runs an SMP agent instance that receives commands and sends responses via 'TBQueue's. runAgentClient :: AgentClient -> AM' () @@ -651,14 +652,14 @@ joinConnAsync c userId corrId enableNtfs cReqUri@CRInvitationUri {} cInfo pqSup pure connId Nothing -> throwError $ AGENT A_VERSION joinConnAsync _c _userId _corrId _enableNtfs (CRContactUri _) _subMode _cInfo _pqEncryption = - throwError $ CMD PROHIBITED + throwE $ CMD PROHIBITED "joinConnAsync" allowConnectionAsync' :: AgentClient -> ACorrId -> ConnId -> ConfirmationId -> ConnInfo -> AM () allowConnectionAsync' c corrId connId confId ownConnInfo = withStore c (`getConn` connId) >>= \case SomeConn _ (RcvConnection _ RcvQueue {server}) -> enqueueCommand c corrId connId (Just server) $ AClientCommand $ APC SAEConn $ LET confId ownConnInfo - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "allowConnectionAsync" acceptContactAsync' :: AgentClient -> ACorrId -> Bool -> InvitationId -> ConnInfo -> PQSupport -> SubscriptionMode -> AM ConnId acceptContactAsync' c corrId enableNtfs invId ownConnInfo pqSupport subMode = do @@ -669,7 +670,7 @@ acceptContactAsync' c corrId enableNtfs invId ownConnInfo pqSupport subMode = do joinConnAsync c userId corrId enableNtfs connReq ownConnInfo pqSupport subMode `catchAgentError` \err -> do withStore' c (`unacceptInvitation` invId) throwError err - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "acceptContactAsync" ackMessageAsync' :: AgentClient -> ACorrId -> ConnId -> AgentMsgId -> Maybe MsgReceiptInfo -> AM () ackMessageAsync' c corrId connId msgId rcptInfo_ = do @@ -678,14 +679,14 @@ ackMessageAsync' c corrId connId msgId rcptInfo_ = do SCDuplex -> enqueueAck SCRcv -> enqueueAck SCSnd -> throwError $ CONN SIMPLEX - SCContact -> throwError $ CMD PROHIBITED - SCNew -> throwError $ CMD PROHIBITED + SCContact -> throwE $ CMD PROHIBITED "ackMessageAsync: SCContact" + SCNew -> throwE $ CMD PROHIBITED "ackMessageAsync: SCNew" where enqueueAck :: AM () enqueueAck = do let mId = InternalId msgId RcvMsg {msgType} <- withStore c $ \db -> getRcvMsg db connId mId - when (isJust rcptInfo_ && msgType /= AM_A_MSG_) $ throwError $ CMD PROHIBITED + when (isJust rcptInfo_ && msgType /= AM_A_MSG_) $ throwE $ CMD PROHIBITED "ackMessageAsync: receipt not allowed" (RcvQueue {server}, _) <- withStore c $ \db -> setMsgUserAck db connId mId enqueueCommand c corrId connId (Just server) . AClientCommand $ APC SAEConn $ ACK msgId rcptInfo_ @@ -713,14 +714,14 @@ switchConnectionAsync' c corrId connId = withConnLock c connId "switchConnectionAsync" $ withStore c (`getConn` connId) >>= \case SomeConn _ (DuplexConnection cData rqs@(rq :| _rqs) sqs) - | isJust (switchingRQ rqs) -> throwError $ CMD PROHIBITED + | isJust (switchingRQ rqs) -> throwE $ CMD PROHIBITED "switchConnectionAsync: already switching" | otherwise -> do - when (ratchetSyncSendProhibited cData) $ throwError $ CMD PROHIBITED + when (ratchetSyncSendProhibited cData) $ throwE $ CMD PROHIBITED "switchConnectionAsync: send prohibited" rq1 <- withStore' c $ \db -> setRcvSwitchStatus db rq $ Just RSSwitchStarted enqueueCommand c corrId connId Nothing $ AClientCommand $ APC SAEConn SWCH let rqs' = updatedQs rq1 rqs pure . connectionStats $ DuplexConnection cData rqs' sqs - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "switchConnectionAsync: not duplex" newConn :: AgentClient -> UserId -> ConnId -> Bool -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys -> SubscriptionMode -> AM (ConnId, ConnectionRequestUri c) newConn c userId connId enableNtfs cMode clientData pqInitKeys subMode = @@ -737,7 +738,7 @@ newConnSrv c userId connId hasNewConn enableNtfs cMode clientData pqInitKeys sub newRcvConnSrv :: AgentClient -> UserId -> ConnId -> Bool -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys -> SubscriptionMode -> SMPServerWithAuth -> AM (ConnId, ConnectionRequestUri c) newRcvConnSrv c userId connId enableNtfs cMode clientData pqInitKeys subMode srv = do case (cMode, pqInitKeys) of - (SCMContact, CR.IKUsePQ) -> throwError $ CMD PROHIBITED + (SCMContact, CR.IKUsePQ) -> throwE $ CMD PROHIBITED "newRcvConnSrv" _ -> pure () AgentConfig {smpClientVRange, smpAgentVRange, e2eEncryptVRange} <- asks config (rq, qUri, tSess, sessId) <- newRcvQueue c userId connId srv smpClientVRange subMode `catchAgentError` \e -> liftIO (print e) >> throwError e @@ -863,7 +864,7 @@ joinConnSrvAsync c userId connId enableNtfs inv@CRInvitationUri {} cInfo pqSuppo ExceptT $ updateNewConnSnd db connId q confirmQueueAsync c cData q' srv cInfo (Just e2eSndParams) subMode joinConnSrvAsync _c _userId _connId _enableNtfs (CRContactUri _) _cInfo _subMode _pqSupport _srv = do - throwError $ CMD PROHIBITED + throwE $ CMD PROHIBITED "joinConnSrvAsync" createReplyQueue :: AgentClient -> ConnData -> SndQueue -> SubscriptionMode -> SMPServerWithAuth -> AM SMPQueueInfo createReplyQueue c ConnData {userId, connId, enableNtfs} SndQueue {smpClientVersion} subMode srv = do @@ -888,7 +889,7 @@ allowConnection' c connId confId ownConnInfo = withConnLock c connId "allowConne liftIO $ setRcvQueueConfirmedE2E db rq dhSecret $ min v v' pure senderKey enqueueCommand c "" connId (Just server) . AInternalCommand $ ICAllowSecure rcvId senderKey - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "allowConnection" -- | Accept contact (ACPT command) in Reader monad acceptContact' :: AgentClient -> ConnId -> Bool -> InvitationId -> ConnInfo -> PQSupport -> SubscriptionMode -> AM ConnId @@ -900,7 +901,7 @@ acceptContact' c connId enableNtfs invId ownConnInfo pqSupport subMode = withCon joinConn c userId connId False enableNtfs connReq ownConnInfo pqSupport subMode `catchAgentError` \err -> do withStore' c (`unacceptInvitation` invId) throwError err - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "acceptContact" -- | Reject contact (RJCT command) in Reader monad rejectContact' :: AgentClient -> ConnId -> InvitationId -> AM () @@ -1000,14 +1001,14 @@ resubscribeConnections' c connIds = do getConnectionMessage' :: AgentClient -> ConnId -> AM (Maybe SMPMsgMeta) getConnectionMessage' c connId = do - whenM (atomically $ hasActiveSubscription c connId) . throwError $ CMD PROHIBITED + whenM (atomically $ hasActiveSubscription c connId) . throwE $ CMD PROHIBITED "getConnectionMessage: subscribed" SomeConn _ conn <- withStore c (`getConn` connId) case conn of DuplexConnection _ (rq :| _) _ -> getQueueMessage c rq RcvConnection _ rq -> getQueueMessage c rq ContactConnection _ rq -> getQueueMessage c rq SndConnection _ _ -> throwError $ CONN SIMPLEX - NewConnection _ -> throwError $ CMD PROHIBITED + NewConnection _ -> throwE $ CMD PROHIBITED "getConnectionMessage: NewConnection" getNotificationMessage' :: AgentClient -> C.CbNonce -> ByteString -> AM (NotificationInfo, [SMPMsgMeta]) getNotificationMessage' c nonce encNtfInfo = do @@ -1019,7 +1020,7 @@ getNotificationMessage' c nonce encNtfInfo = do ntfMsgMeta <- (eitherToMaybe . smpDecode <$> agentCbDecrypt rcvNtfDhSecret nmsgNonce encNMsgMeta) `catchAgentError` \_ -> pure Nothing maxMsgs <- asks $ ntfMaxMessages . config (NotificationInfo {ntfConnId, ntfTs, ntfMsgMeta},) <$> getNtfMessages ntfConnId ntfMsgMeta maxMsgs - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "getNotificationMessage" where getNtfMessages ntfConnId nMeta = getMsg where @@ -1071,7 +1072,7 @@ sendMessagesB_ c reqs connIds = withConnLocks c connIds "sendMessages" $ do where prepareMsg :: ConnData -> NonEmpty SndQueue -> ([ConnId], Either AgentErrorType (ConnData, NonEmpty SndQueue, Maybe PQEncryption, MsgFlags, AMessage)) prepareMsg cData@ConnData {connId, pqSupport} sqs - | ratchetSyncSendProhibited cData = (acc, Left $ CMD PROHIBITED) + | ratchetSyncSendProhibited cData = (acc, Left $ CMD PROHIBITED "sendMessagesB: send prohibited") -- connection is only updated if PQ encryption was disabled, and now it has to be enabled. -- support for PQ encryption (small message envelopes) will not be disabled when message is sent. | pqEnc == PQEncOn && pqSupport == PQSupportOff = @@ -1133,7 +1134,7 @@ runCommandProcessing c@AgentClient {subQ} server_ Worker {doWork} = do withStore c (`getConn` connId) >>= \case SomeConn _ conn@(DuplexConnection _ (replaced :| _rqs) _) -> switchDuplexConnection c conn replaced >>= notify . SWITCH QDRcv SPStarted - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "SWCH: not duplex" DEL -> withServer' . tryCommand $ deleteConnection' c connId >> notify OK _ -> notify $ ERR $ INTERNAL $ "unsupported async command " <> show (aCommandTag cmd) AInternalCommand cmd -> case cmd of @@ -1479,8 +1480,8 @@ ackMessage' c connId msgId rcptInfo_ = withConnLock c connId "ackMessage" $ do DuplexConnection {} -> ack >> sendRcpt conn >> del RcvConnection {} -> ack >> del SndConnection {} -> throwError $ CONN SIMPLEX - ContactConnection {} -> throwError $ CMD PROHIBITED - NewConnection _ -> throwError $ CMD PROHIBITED + ContactConnection {} -> throwE $ CMD PROHIBITED "ackMessage: ContactConnection" + NewConnection _ -> throwE $ CMD PROHIBITED "ackMessage: NewConnection" where ack :: AM () ack = do @@ -1494,7 +1495,7 @@ ackMessage' c connId msgId rcptInfo_ = withConnLock c connId "ackMessage" $ do msg@RcvMsg {msgType, msgReceipt} <- withStore c $ \db -> getRcvMsg db connId $ InternalId msgId case rcptInfo_ of Just rcptInfo -> do - unless (msgType == AM_A_MSG_) $ throwError (CMD PROHIBITED) + unless (msgType == AM_A_MSG_) . throwE $ CMD PROHIBITED "ackMessage: receipt not allowed" when (connAgentVersion >= deliveryRcptsSMPAgentVersion) $ do let RcvMsg {msgMeta = MsgMeta {sndMsgId}, internalHash} = msg rcpt = A_RCVD [AMessageReceipt {agentMsgId = sndMsgId, msgHash = internalHash, rcptInfo}] @@ -1510,12 +1511,12 @@ switchConnection' c connId = withConnLock c connId "switchConnection" $ withStore c (`getConn` connId) >>= \case SomeConn _ conn@(DuplexConnection cData rqs@(rq :| _rqs) _) - | isJust (switchingRQ rqs) -> throwError $ CMD PROHIBITED + | isJust (switchingRQ rqs) -> throwE $ CMD PROHIBITED "switchConnection: already switching" | otherwise -> do - when (ratchetSyncSendProhibited cData) $ throwError $ CMD PROHIBITED + when (ratchetSyncSendProhibited cData) $ throwE $ CMD PROHIBITED "switchConnection: send prohibited" rq' <- withStore' c $ \db -> setRcvSwitchStatus db rq $ Just RSSwitchStarted switchDuplexConnection c conn rq' - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "switchConnection: not duplex" switchDuplexConnection :: AgentClient -> Connection 'CDuplex -> RcvQueue -> AM ConnectionStats switchDuplexConnection c (DuplexConnection cData@ConnData {connId, userId} rqs sqs) rq@RcvQueue {server, dbQueueId = DBQueueId dbQueueId, sndId} = do @@ -1540,7 +1541,7 @@ abortConnectionSwitch' c connId = SomeConn _ (DuplexConnection cData rqs sqs) -> case switchingRQ rqs of Just rq | canAbortRcvSwitch rq -> do - when (ratchetSyncSendProhibited cData) $ throwError $ CMD PROHIBITED + when (ratchetSyncSendProhibited cData) $ throwE $ CMD PROHIBITED "abortConnectionSwitch: send prohibited" -- multiple queues to which the connections switches were possible when repeating switch was allowed let (delRqs, keepRqs) = L.partition ((Just (dbQId rq) ==) . dbReplaceQId) rqs case L.nonEmpty keepRqs of @@ -1553,9 +1554,9 @@ abortConnectionSwitch' c connId = conn' = DuplexConnection cData rqs'' sqs pure $ connectionStats conn' _ -> throwError $ INTERNAL "won't delete all rcv queues in connection" - | otherwise -> throwError $ CMD PROHIBITED - _ -> throwError $ CMD PROHIBITED - _ -> throwError $ CMD PROHIBITED + | otherwise -> throwE $ CMD PROHIBITED "abortConnectionSwitch: no rcv queues left" + _ -> throwE $ CMD PROHIBITED "abortConnectionSwitch: not allowed" + _ -> throwE $ CMD PROHIBITED "abortConnectionSwitch: not duplex" synchronizeRatchet' :: AgentClient -> ConnId -> PQSupport -> Bool -> AM ConnectionStats synchronizeRatchet' c connId pqSupport' force = withConnLock c connId "synchronizeRatchet" $ do @@ -1575,8 +1576,8 @@ synchronizeRatchet' c connId pqSupport' force = withConnLock c connId "synchroni let cData'' = cData' {ratchetSyncState = RSStarted} :: ConnData conn' = DuplexConnection cData'' rqs sqs pure $ connectionStats conn' - | otherwise -> throwError $ CMD PROHIBITED - _ -> throwError $ CMD PROHIBITED + | otherwise -> throwE $ CMD PROHIBITED "synchronizeRatchet: not allowed" + _ -> throwE $ CMD PROHIBITED "synchronizeRatchet: not duplex" ackQueueMessage :: AgentClient -> RcvQueue -> SMP.MsgId -> AM () ackQueueMessage c rq srvMsgId = @@ -1593,7 +1594,7 @@ suspendConnection' c connId = withConnLock c connId "suspendConnection" $ do RcvConnection _ rq -> suspendQueue c rq ContactConnection _ rq -> suspendQueue c rq SndConnection _ _ -> throwError $ CONN SIMPLEX - NewConnection _ -> throwError $ CMD PROHIBITED + NewConnection _ -> throwE $ CMD PROHIBITED "suspendConnection" -- | Delete SMP agent connection (DEL command) in Reader monad -- unlike deleteConnectionAsync, this function does not mark connection as deleted in case of deletion failure @@ -1831,7 +1832,7 @@ registerNtfToken' c suppliedDeviceToken suppliedNtfMode = withStore' c (`createNtfToken` tkn) registerToken tkn pure NTRegistered - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "createToken" registerToken :: NtfToken -> AM () registerToken tkn@NtfToken {ntfPubKey, ntfDhKeys = (pubDhKey, privDhKey)} = do (tknId, srvPubDhKey) <- agentNtfRegisterToken c tkn ntfPubKey pubDhKey @@ -1844,7 +1845,7 @@ verifyNtfToken' :: AgentClient -> DeviceToken -> C.CbNonce -> ByteString -> AM ( verifyNtfToken' c deviceToken nonce code = withStore' c getSavedNtfToken >>= \case Just tkn@NtfToken {deviceToken = savedDeviceToken, ntfTokenId = Just tknId, ntfDhSecret = Just dhSecret, ntfMode} -> do - when (deviceToken /= savedDeviceToken) . throwError $ CMD PROHIBITED + when (deviceToken /= savedDeviceToken) . throwE $ CMD PROHIBITED "verifyNtfToken: different token" code' <- liftEither . bimap cryptoError NtfRegCode $ C.cbDecrypt dhSecret nonce code toStatus <- withToken c tkn (Just (NTConfirmed, NTAVerify code')) (NTActive, Just NTACheck) $ @@ -1853,36 +1854,36 @@ verifyNtfToken' c deviceToken nonce code = cron <- asks $ ntfCron . config agentNtfEnableCron c tknId tkn cron when (ntfMode == NMInstant) $ initializeNtfSubs c - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "verifyNtfToken: no token" checkNtfToken' :: AgentClient -> DeviceToken -> AM NtfTknStatus checkNtfToken' c deviceToken = withStore' c getSavedNtfToken >>= \case Just tkn@NtfToken {deviceToken = savedDeviceToken, ntfTokenId = Just tknId} -> do - when (deviceToken /= savedDeviceToken) . throwError $ CMD PROHIBITED + when (deviceToken /= savedDeviceToken) . throwE $ CMD PROHIBITED "checkNtfToken: different token" agentNtfCheckToken c tknId tkn - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "checkNtfToken: no token" deleteNtfToken' :: AgentClient -> DeviceToken -> AM () deleteNtfToken' c deviceToken = withStore' c getSavedNtfToken >>= \case Just tkn@NtfToken {deviceToken = savedDeviceToken} -> do - when (deviceToken /= savedDeviceToken) . throwError $ CMD PROHIBITED + when (deviceToken /= savedDeviceToken) . throwE $ CMD PROHIBITED "deleteNtfToken: different token" deleteToken_ c tkn deleteNtfSubs c NSCSmpDelete - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "deleteNtfToken: no token" getNtfToken' :: AgentClient -> AM (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer) getNtfToken' c = withStore' c getSavedNtfToken >>= \case Just NtfToken {deviceToken, ntfTknStatus, ntfMode, ntfServer} -> pure (deviceToken, ntfTknStatus, ntfMode, ntfServer) - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "getNtfToken" getNtfTokenData' :: AgentClient -> AM NtfToken getNtfTokenData' c = withStore' c getSavedNtfToken >>= \case Just tkn -> pure tkn - _ -> throwError $ CMD PROHIBITED + _ -> throwE $ CMD PROHIBITED "getNtfTokenData" -- | Set connection notifications, in Reader monad toggleConnectionNtfs' :: AgentClient -> ConnId -> Bool -> AM () @@ -2172,7 +2173,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) smpConfirmation srvMsgId conn senderKey e2ePubKey e2eEncryption_ encConnInfo phVer agentVersion >> ack (SMP.PHEmpty, AgentInvitation {connReq, connInfo}) -> smpInvitation srvMsgId conn connReq connInfo >> ack - _ -> prohibited >> ack + _ -> prohibited "handshake: incorrect state" >> ack (Just e2eDh, Nothing) -> do decryptClientMessage e2eDh clientMsg >>= \case (SMP.PHEmpty, AgentRatchetKey {agentVersion, e2eEncryption}) -> do @@ -2196,7 +2197,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) let encryptedMsgHash = C.sha256Hash encAgentMessage g <- asks random atomically updateTotalMsgCount - tryError (agentClientMsg g encryptedMsgHash) >>= \case + tryAgentError (agentClientMsg g encryptedMsgHash) >>= \case Right (Just (msgId, msgMeta, aMessage, rcPrev)) -> do conn'' <- resetRatchetSync case aMessage of @@ -2227,13 +2228,13 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) withStore' c $ \db -> setConnRatchetSync db connId RSOk pure conn'' | otherwise = pure conn' - Right _ -> prohibited >> ack + Right Nothing -> prohibited "msg: bad agent msg" >> ack Left e@(AGENT A_DUPLICATE) -> do atomically updateDupMsgCount withStore' c (\db -> getLastMsg db connId srvMsgId) >>= \case Just RcvMsg {internalId, msgMeta, msgBody = agentMsgBody, userAck} | userAck -> ackDel internalId - | otherwise -> do + | otherwise -> liftEither (parse smpP (AGENT A_MESSAGE) agentMsgBody) >>= \case AgentMessage _ (A_MSG body) -> do logServer "<--" c srv rId $ "MSG :" <> logSecret srvMsgId @@ -2292,8 +2293,8 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) liftIO $ createRcvMsg db connId rq rcvMsg pure $ Just (internalId, msgMeta, aMessage, rc) _ -> pure Nothing - _ -> prohibited >> ack - _ -> prohibited >> ack + _ -> prohibited "msg: bad client msg" >> ack + _ -> prohibited "msg: no keys" >> ack updateConnVersion :: Connection c -> ConnData -> VersionSMPA -> AM (Connection c) updateConnVersion conn' cData' msgAgentVersion = do aVRange <- asks $ smpAgentVRange . config @@ -2330,8 +2331,8 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) notify :: forall e m. (AEntityI e, MonadIO m) => ACommand 'Agent e -> m () notify = notify' connId - prohibited :: AM () - prohibited = notify . ERR $ AGENT A_PROHIBITED + prohibited :: String -> AM () + prohibited = notify . ERR . AGENT . A_PROHIBITED enqueueCmd :: InternalCommand -> AM () enqueueCmd = enqueueCommand c "" connId (Just srv) . AInternalCommand @@ -2383,7 +2384,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) parseMessage agentMsgBody >>= \case AgentConnInfoReply smpQueues connInfo -> processConf connInfo SMPConfirmation {senderKey, e2ePubKey, connInfo, smpReplyQueues = L.toList smpQueues, smpClientVersion} - _ -> prohibited -- including AgentConnInfo, that is prohibited here in v2 + _ -> prohibited "conf: not AgentConnInfoReply" -- including AgentConnInfo, that is prohibited here in v2 where processConf connInfo senderConf = do let newConfirmation = NewConfirmation {connId, senderConf, ratchetState = rc'} @@ -2393,7 +2394,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) createConfirmation db g newConfirmation let srvs = map qServer $ smpReplyQueues senderConf notify $ CONF confId pqSupport' srvs connInfo - _ -> prohibited + _ -> prohibited "conf: decrypt error or skipped" -- party accepting connection (DuplexConnection _ (RcvQueue {smpClientVersion = v'} :| _) _, Nothing) -> do g <- asks random @@ -2403,15 +2404,15 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) let dhSecret = C.dh' e2ePubKey e2ePrivKey withStore' c $ \db -> setRcvQueueConfirmedE2E db rq dhSecret $ min v' smpClientVersion enqueueCmd $ ICDuplexSecure rId senderKey - _ -> prohibited - _ -> prohibited - _ -> prohibited + _ -> prohibited "conf: not AgentConnInfo" + _ -> prohibited "conf: incorrect state" + _ -> prohibited "conf: status /= new" helloMsg :: SMP.MsgId -> MsgMeta -> Connection c -> AM () helloMsg srvMsgId MsgMeta {pqEncryption} conn' = do logServer "<--" c srv rId $ "MSG :" <> logSecret srvMsgId case status of - Active -> prohibited + Active -> prohibited "hello: active" _ -> case conn' of DuplexConnection _ _ (sq@SndQueue {status = sndStatus} :| _) @@ -2453,7 +2454,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) let sndMsgId = InternalSndId agentMsgId SndMsg {internalId = InternalId msgId, msgType, internalHash, msgReceipt} <- withStore c $ \db -> getSndMsgViaRcpt db connId sndMsgId if msgType /= AM_A_MSG_ - then notify (ERR $ AGENT A_PROHIBITED) $> Nothing -- unexpected message type for receipt + then prohibited "receipt: not a msg" $> Nothing else case msgReceipt of Just MsgReceipt {msgRcptStatus = MROk} -> pure Nothing -- already notified with MROk status _ -> do @@ -2561,7 +2562,7 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) invId <- withStore c $ \db -> createInvitation db g newInv let srvs = L.map qServer $ crSmpQueues crData notify $ REQ invId pqSupport srvs cInfo - _ -> prohibited + _ -> prohibited "inv: sent to message conn" where pqSupported (_, Compatible (CR.E2ERatchetParams v _ _ _), Compatible agentVersion) = PQSupportOn `CR.pqSupportAnd` versionPQSupport_ agentVersion (Just v) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 0d0af894f..37f659c9f 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -1327,7 +1327,7 @@ subscribeQueues c qs = do where checkQueue rq = do prohibited <- atomically $ hasGetLock c rq - pure $ if prohibited then Left (rq, Left $ CMD PROHIBITED) else Right rq + pure $ if prohibited then Left (rq, Left $ CMD PROHIBITED "subscribeQueues") else Right rq subscribeQueues_ :: Env -> TVar (Maybe SessionId) -> SMPClient -> NonEmpty RcvQueue -> IO (BatchResponses SMPClientError ()) subscribeQueues_ env session smp qs' = do rs <- sendBatch subscribeSMPQueues smp qs' @@ -1595,7 +1595,7 @@ agentXFTPNewChunk :: AgentClient -> SndFileChunk -> Int -> XFTPServerWithAuth -> agentXFTPNewChunk c SndFileChunk {userId, chunkSpec = XFTPChunkSpec {chunkSize}, digest = FileDigest chunkDigest} n (ProtoServerWithAuth srv auth) = do rKeys <- xftpRcvKeys n (sndKey, replicaKey) <- atomically . C.generateAuthKeyPair C.SEd25519 =<< asks random - let fileInfo = FileInfo {sndKey, size = fromIntegral chunkSize, digest = chunkDigest} + let fileInfo = FileInfo {sndKey, size = chunkSize, digest = chunkDigest} logServer "-->" c srv "" "FNEW" tSess <- liftIO $ mkTransportSession c userId srv chunkDigest (sndId, rIds) <- withClient c tSess "FNEW" $ \xftp -> X.createXFTPChunk xftp replicaKey fileInfo (L.map fst rKeys) auth @@ -1658,7 +1658,7 @@ agentCbDecrypt dhSecret nonce msg = cryptoError :: C.CryptoError -> AgentErrorType cryptoError = \case - C.CryptoLargeMsgError -> CMD LARGE + C.CryptoLargeMsgError -> CMD LARGE "CryptoLargeMsgError" C.CryptoHeaderError _ -> AGENT A_MESSAGE -- parsing error C.CERatchetDuplicateMessage -> AGENT A_DUPLICATE C.AESDecryptError -> c DECRYPT_AES @@ -1801,7 +1801,7 @@ storeError = \case SEConnDuplicate -> CONN DUPLICATE SEBadConnType CRcv -> CONN SIMPLEX SEBadConnType CSnd -> CONN SIMPLEX - SEInvitationNotFound -> CMD PROHIBITED + SEInvitationNotFound -> CMD PROHIBITED "SEInvitationNotFound" -- this error is never reported as store error, -- it is used to wrap agent operations when "transaction-like" store access is needed -- NOTE: network IO should NOT be used inside AgentStoreMonad diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 1fc984ac5..0b40edee9 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -157,8 +157,8 @@ where import Control.Applicative (optional, (<|>)) import Control.Monad (unless) -import Control.Monad.Except (runExceptT, throwError) import Control.Monad.IO.Class +import Control.Monad.Trans.Except import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson.TH as J import Data.Attoparsec.ByteString.Char8 (Parser) @@ -1471,7 +1471,7 @@ instance StrEncoding MsgErrorType where -- | Error type used in errors sent to agent clients. data AgentErrorType = -- | command or response error - CMD {cmdErr :: CommandErrorType} + CMD {cmdErr :: CommandErrorType, errContext :: String} | -- | connection errors CONN {connErr :: ConnectionErrorType} | -- | SMP protocol errors forwarded to agent clients @@ -1529,7 +1529,7 @@ data SMPAgentError = -- | client or agent message that failed to parse A_MESSAGE | -- | prohibited SMP/agent message - A_PROHIBITED + A_PROHIBITED {prohibitedErr :: String} | -- | incompatible version of SMP client, agent or encryption protocols A_VERSION | -- | cannot decrypt message @@ -1578,7 +1578,7 @@ instance StrEncoding AgentErrorType where strP = A.takeTill (== ' ') >>= \case - "CMD" -> CMD <$> (A.space *> parseRead1) + "CMD" -> CMD <$> (A.space *> parseRead1) <*> (A.space *> textP) "CONN" -> CONN <$> (A.space *> parseRead1) "SMP" -> SMP <$> (A.space *> srvP) <*> _strP "NTF" -> NTF <$> (A.space *> srvP) <*> _strP @@ -1593,9 +1593,8 @@ instance StrEncoding AgentErrorType where _ -> fail "bad AgentErrorType" where srvP = T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ') - textP = T.unpack . safeDecodeUtf8 <$> A.takeByteString strEncode = \case - CMD e -> "CMD " <> bshow e + CMD e cxt -> "CMD " <> bshow e <> " " <> text cxt CONN e -> "CONN " <> bshow e SMP srv e -> "SMP " <> text srv <> " " <> strEncode e NTF srv e -> "NTF " <> text srv <> " " <> strEncode e @@ -1615,20 +1614,23 @@ instance StrEncoding SMPAgentError where A.takeTill (== ' ') >>= \case "MESSAGE" -> pure A_MESSAGE - "PROHIBITED" -> pure A_PROHIBITED + "PROHIBITED" -> A_PROHIBITED <$> (A.space *> textP) "VERSION" -> pure A_VERSION "CRYPTO" -> A_CRYPTO <$> _strP "DUPLICATE" -> pure A_DUPLICATE - "QUEUE" -> A_QUEUE . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString) + "QUEUE" -> A_QUEUE <$> (A.space *> textP) _ -> fail "bad SMPAgentError" strEncode = \case A_MESSAGE -> "MESSAGE" - A_PROHIBITED -> "PROHIBITED" + A_PROHIBITED e -> "PROHIBITED " <> encodeUtf8 (T.pack e) A_VERSION -> "VERSION" A_CRYPTO e -> "CRYPTO " <> strEncode e A_DUPLICATE -> "DUPLICATE" A_QUEUE e -> "QUEUE " <> encodeUtf8 (T.pack e) +textP :: Parser String +textP = T.unpack . safeDecodeUtf8 <$> A.takeByteString + cryptoErrToSyncState :: AgentCryptoError -> RatchetSyncState cryptoErrToSyncState = \case DECRYPT_AES -> RSAllowed @@ -1841,7 +1843,7 @@ commandP binaryP = sd : rds -> SFDONE <$> strDecode (encodeUtf8 sd) <*> mapM (strDecode . encodeUtf8) rds parseCommand :: ByteString -> Either AgentErrorType ACmd -parseCommand = parse (commandP A.takeByteString) $ CMD SYNTAX +parseCommand = parse (commandP A.takeByteString) $ CMD SYNTAX "parseCommand" -- | Serialize SMP agent command. serializeCommand :: ACommand p e -> ByteString @@ -1931,7 +1933,7 @@ tGet party h = liftIO (tGetRaw h) >>= tParseLoadBody fromParty :: ACmd -> Either AgentErrorType (APartyCmd p) fromParty (ACmd (p :: p1) e cmd) = case testEquality party p of Just Refl -> Right $ APC e cmd - _ -> Left $ CMD PROHIBITED + _ -> Left $ CMD PROHIBITED "fromParty" tConnId :: ARawTransmission -> APartyCmd p -> Either AgentErrorType (APartyCmd p) tConnId (_, entId, _) (APC e cmd) = @@ -1949,7 +1951,7 @@ tGet party h = liftIO (tGetRaw h) >>= tParseLoadBody SUSPENDED {} -> Right cmd -- other responses must have connection ID _ - | B.null entId -> Left $ CMD NO_CONN + | B.null entId -> Left $ CMD NO_CONN "tConnId" | otherwise -> Right cmd cmdWithMsgBody :: APartyCmd p -> IO (Either AgentErrorType (APartyCmd p)) @@ -1972,11 +1974,11 @@ tGet party h = liftIO (tGetRaw h) >>= tParseLoadBody str -> case readMaybe str :: Maybe Int of Just size -> runExceptT $ do body <- liftIO $ cGet h size - unless (B.length body == size) $ throwError $ CMD SIZE + unless (B.length body == size) $ throwE $ CMD SIZE "getBody" s <- liftIO $ getLn h - unless (B.null s) $ throwError $ CMD SIZE + unless (B.null s) $ throwE $ CMD SIZE "getBody" pure body - Nothing -> return . Left $ CMD SYNTAX + Nothing -> pure . Left $ CMD SYNTAX "getBody" $(J.deriveJSON defaultJSON ''RcvQueueInfo) diff --git a/src/Simplex/Messaging/Agent/Store/SQLite.hs b/src/Simplex/Messaging/Agent/Store/SQLite.hs index 04dd826a6..6c2c5906d 100644 --- a/src/Simplex/Messaging/Agent/Store/SQLite.hs +++ b/src/Simplex/Messaging/Agent/Store/SQLite.hs @@ -2284,7 +2284,7 @@ createRcvFile db gVar userId fd@FileDescription {chunks} prefixPath tmpPath file forM_ (zip [1 ..] replicas) $ \(rno, replica) -> insertRcvFileChunkReplica db rno replica chunkId pure rcvFileEntityId -createRcvFileRedirect :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription FRecipient -> FilePath -> FilePath -> CryptoFile -> FilePath -> CryptoFile -> Bool -> IO (Either StoreError RcvFileId) +createRcvFileRedirect :: DB.Connection -> TVar ChaChaDRG -> UserId -> FileDescription 'FRecipient -> FilePath -> FilePath -> CryptoFile -> FilePath -> CryptoFile -> Bool -> IO (Either StoreError RcvFileId) createRcvFileRedirect _ _ _ FileDescription {redirect = Nothing} _ _ _ _ _ _ = pure $ Left $ SEInternal "createRcvFileRedirect called without redirect" createRcvFileRedirect db gVar userId redirectFd@FileDescription {chunks = redirectChunks, redirect = Just RedirectFileInfo {size, digest}} prefixPath redirectPath redirectFile dstPath dstFile approvedRelays = runExceptT $ do (dstEntityId, dstId) <- ExceptT $ insertRcvFile db gVar userId dummyDst prefixPath dstPath dstFile Nothing Nothing approvedRelays diff --git a/src/Simplex/Messaging/Crypto/Ratchet.hs b/src/Simplex/Messaging/Crypto/Ratchet.hs index db13fddc1..14f567820 100644 --- a/src/Simplex/Messaging/Crypto/Ratchet.hs +++ b/src/Simplex/Messaging/Crypto/Ratchet.hs @@ -166,9 +166,9 @@ instance TestEquality SRatchetKEMState where class RatchetKEMStateI (s :: RatchetKEMState) where sRatchetKEMState :: SRatchetKEMState s -instance RatchetKEMStateI RKSProposed where sRatchetKEMState = SRKSProposed +instance RatchetKEMStateI 'RKSProposed where sRatchetKEMState = SRKSProposed -instance RatchetKEMStateI RKSAccepted where sRatchetKEMState = SRKSAccepted +instance RatchetKEMStateI 'RKSAccepted where sRatchetKEMState = SRKSAccepted checkRatchetKEMState :: forall t s s' a. (RatchetKEMStateI s, RatchetKEMStateI s') => t s' a -> Either String (t s a) checkRatchetKEMState x = case testEquality (sRatchetKEMState @s) (sRatchetKEMState @s') of diff --git a/src/Simplex/Messaging/Transport/HTTP2/Client.hs b/src/Simplex/Messaging/Transport/HTTP2/Client.hs index d701d4114..71757ca6d 100644 --- a/src/Simplex/Messaging/Transport/HTTP2/Client.hs +++ b/src/Simplex/Messaging/Transport/HTTP2/Client.hs @@ -14,6 +14,7 @@ import Control.Monad import Data.ByteString.Char8 (ByteString) import Data.Functor (($>)) import Data.Time (UTCTime, getCurrentTime) +import qualified Data.X509 as X import qualified Data.X509.CertificateStore as XS import Network.HPACK (BufferSize) import Network.HTTP2.Client (ClientConfig (..), Request, Response) @@ -29,7 +30,6 @@ import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM import UnliftIO.Timeout -import qualified Data.X509 as X data HTTP2Client = HTTP2Client { action :: Maybe (Async HTTP2Response), diff --git a/src/Simplex/Messaging/Transport/HTTP2/File.hs b/src/Simplex/Messaging/Transport/HTTP2/File.hs index 10238f161..aef98acaa 100644 --- a/src/Simplex/Messaging/Transport/HTTP2/File.hs +++ b/src/Simplex/Messaging/Transport/HTTP2/File.hs @@ -23,7 +23,7 @@ hReceiveFile getBody h size = get $ fromIntegral size if | chSize > sz -> pure (chSize - sz) | chSize > 0 -> B.hPut h ch >> get (sz - chSize) - | otherwise -> pure (-fromIntegral sz) + | otherwise -> pure (-sz) hSendFile :: Handle -> (Builder -> IO ()) -> Word32 -> IO () hSendFile h send = go diff --git a/src/Simplex/Messaging/Transport/Server.hs b/src/Simplex/Messaging/Transport/Server.hs index 145b438e0..495ad76bb 100644 --- a/src/Simplex/Messaging/Transport/Server.hs +++ b/src/Simplex/Messaging/Transport/Server.hs @@ -118,7 +118,7 @@ runTCPServerSocket (accepted, gracefullyClosed, clients) started getSocket serve let closeConn _ = do atomically $ modifyTVar' clients $ IM.delete cId gracefulClose conn 5000 `catchAll_` pure () -- catchAll_ is needed here in case the connection was closed earlier - atomically $ modifyTVar' gracefullyClosed (+1) + atomically $ modifyTVar' gracefullyClosed (+ 1) tId <- mkWeakThreadId =<< server conn `forkFinally` closeConn atomically $ modifyTVar' clients $ IM.insert cId tId diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index 7a3077532..186c91e13 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -7,7 +7,6 @@ {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module AgentTests (agentTests) where @@ -358,7 +357,7 @@ testRejectContactRequest _ alice bob = do -- RJCT must use correct contact connection alice #: ("2a", "bob", "RJCT " <> aInvId) #> ("2a", "bob", ERR $ CONN NOT_FOUND) alice #: ("2b", "a_contact", "RJCT " <> aInvId) #> ("2b", "a_contact", OK) - alice #: ("3", "bob", "ACPT " <> aInvId <> " 12\nalice's info") #> ("3", "bob", ERR $ A.CMD PROHIBITED) + alice #: ("3", "bob", "ACPT " <> aInvId <> " 12\nalice's info") =#> \case ("3", "bob", ERR (A.CMD PROHIBITED _)) -> True; _ -> False bob #:# "nothing should be delivered to bob" testSubscription :: Transport c => TProxy c -> c -> c -> c -> IO () @@ -430,8 +429,8 @@ testServerConnectionAfterError t _ = do withAgent1 $ \bob -> do withAgent2 $ \alice -> do - bob #:! ("1", "alice", "SUB") =#> \("1", "alice", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT - alice #:! ("1", "bob", "SUB") =#> \("1", "bob", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT + bob #:! ("1", "alice", "SUB") =#> \case ("1", "alice", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT; _ -> False + alice #:! ("1", "bob", "SUB") =#> \case ("1", "bob", ERR (BROKER _ e)) -> e == NETWORK || e == TIMEOUT; _ -> False withServer $ do alice <#=? \case ("", "bob", APC SAEConn (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False alice <#=? \case ("", "bob", APC SAEConn (SENT 4)) -> True; ("", "", APC _ (UP s ["bob"])) -> s == server; _ -> False @@ -613,12 +612,12 @@ sampleDhKey = "MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o=" syntaxTests :: forall c. Transport c => TProxy c -> Spec syntaxTests t = do - it "unknown command" $ ("1", "5678", "HELLO") >#> ("1", "5678", "ERR CMD SYNTAX") + it "unknown command" $ ("1", "5678", "HELLO") >#> ("1", "5678", "ERR CMD SYNTAX parseCommand") describe "NEW" $ do describe "valid" $ do it "with correct parameter" $ ("211", "", "NEW T INV subscribe") >#>= \case ("211", _, "INV" : _) -> True; _ -> False describe "invalid" $ do - it "with incorrect parameter" $ ("222", "", "NEW T hi subscribe") >#> ("222", "", "ERR CMD SYNTAX") + it "with incorrect parameter" $ ("222", "", "NEW T hi subscribe") >#> ("222", "", "ERR CMD SYNTAX parseCommand") describe "JOIN" $ do describe "valid" $ do @@ -636,7 +635,7 @@ syntaxTests t = do ) >#> ("311", "a", "ERR SMP smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001 AUTH") describe "invalid" $ do - it "no parameters" $ ("321", "", "JOIN") >#> ("321", "", "ERR CMD SYNTAX") + it "no parameters" $ ("321", "", "JOIN") >#> ("321", "", "ERR CMD SYNTAX parseCommand") where -- simple test for one command with the expected response (>#>) :: ARawTransmission -> ARawTransmission -> Expectation diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 6e5e09fc3..250e95474 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -14,7 +14,6 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module AgentTests.FunctionalAPITests ( functionalAPITests, @@ -575,13 +574,13 @@ testEnablePQEncryption = -- switched to smaller envelopes (before reporting PQ encryption enabled) sml <- largeMsg g PQSupportOn -- fail because of message size - Left (A.CMD LARGE) <- tryError $ A.sendMessage ca bId PQEncOn SMP.noMsgFlags lrg + Left (A.CMD LARGE _) <- tryError $ A.sendMessage ca bId PQEncOn SMP.noMsgFlags lrg (9, PQEncOff) <- A.sendMessage ca bId PQEncOn SMP.noMsgFlags sml get ca =##> \case ("", connId, SENT 9) -> connId == bId; _ -> False get cb =##> \case ("", connId, MsgErr' 8 MsgSkipped {} PQEncOff msg') -> connId == aId && msg' == sml; _ -> False ackMessage cb aId 8 Nothing -- -- fail in reply to sync IDss - Left (A.CMD LARGE) <- tryError $ A.sendMessage cb aId PQEncOn SMP.noMsgFlags lrg + Left (A.CMD LARGE _) <- tryError $ A.sendMessage cb aId PQEncOn SMP.noMsgFlags lrg (10, PQEncOff) <- A.sendMessage cb aId PQEncOn SMP.noMsgFlags sml get cb =##> \case ("", connId, SENT 10) -> connId == aId; _ -> False get ca =##> \case ("", connId, MsgErr' 10 MsgSkipped {} PQEncOff msg') -> connId == bId && msg' == sml; _ -> False @@ -608,8 +607,8 @@ testEnablePQEncryption = (b, 26, sml) \#>\ a (a, 27, sml) \#>\ b -- PQ encryption is now disabled, but support remained enabled, so we still cannot send larger messages - Left (A.CMD LARGE) <- tryError $ A.sendMessage ca bId PQEncOff SMP.noMsgFlags (sml <> "123456") - Left (A.CMD LARGE) <- tryError $ A.sendMessage cb aId PQEncOff SMP.noMsgFlags (sml <> "123456") + Left (A.CMD LARGE _) <- tryError $ A.sendMessage ca bId PQEncOff SMP.noMsgFlags (sml <> "123456") + Left (A.CMD LARGE _) <- tryError $ A.sendMessage cb aId PQEncOff SMP.noMsgFlags (sml <> "123456") pure () where (\#>\) = PQEncOff `sndRcv` PQEncOff @@ -2480,7 +2479,7 @@ testDeliveryReceipts = get a =##> \case ("", c, Msg "hello too") -> c == bId; _ -> False ackMessage a bId 6 $ Just "" get b =##> \case ("", c, Rcvd 6) -> c == aId; _ -> False - ackMessage b aId 7 (Just "") `catchError` \e -> liftIO $ e `shouldBe` A.CMD PROHIBITED + ackMessage b aId 7 (Just "") `catchError` \case (A.CMD PROHIBITED _) -> pure (); e -> liftIO $ expectationFailure ("unexpected error " <> show e) ackMessage b aId 7 Nothing testDeliveryReceiptsVersion :: HasCallStack => ATransport -> IO () diff --git a/tests/AgentTests/NotificationTests.hs b/tests/AgentTests/NotificationTests.hs index 348694f7e..a7c3fb25a 100644 --- a/tests/AgentTests/NotificationTests.hs +++ b/tests/AgentTests/NotificationTests.hs @@ -7,7 +7,6 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} -{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-} module AgentTests.NotificationTests where @@ -182,7 +181,7 @@ testNotificationToken APNSMockServer {apnsQ} = do NTActive <- checkNtfToken a tkn deleteNtfToken a tkn -- agent deleted this token - Left (CMD PROHIBITED) <- tryE $ checkNtfToken a tkn + Left (CMD PROHIBITED _) <- tryE $ checkNtfToken a tkn pure () (.->) :: J.Value -> J.Key -> ExceptT AgentErrorType IO ByteString @@ -375,7 +374,7 @@ testNotificationSubscriptionExistingConnection APNSMockServer {apnsQ} alice@Agen pure (bobId, aliceId, nonce, message) -- alice client already has subscription for the connection - Left (CMD PROHIBITED) <- runExceptT $ getNotificationMessage alice nonce message + Left (CMD PROHIBITED _) <- runExceptT $ getNotificationMessage alice nonce message -- aliceNtf client doesn't have subscription and is allowed to get notification message withAgent 3 aliceCfg initAgentServers testDB $ \aliceNtf -> runRight_ $ do From 470dc7439183589ab24d530384cff88494895e92 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 24 May 2024 14:25:05 +0100 Subject: [PATCH 083/107] ntf server: do not persist server connection errors --- src/Simplex/Messaging/Notifications/Server/Main.hs | 2 +- tests/NtfClient.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Notifications/Server/Main.hs b/src/Simplex/Messaging/Notifications/Server/Main.hs index b28dd6111..2d2f0bfc4 100644 --- a/src/Simplex/Messaging/Notifications/Server/Main.hs +++ b/src/Simplex/Messaging/Notifications/Server/Main.hs @@ -113,7 +113,7 @@ ntfServerCLI cfgPath logPath = clientQSize = 64, subQSize = 512, pushQSize = 1048, - smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 15}, + smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 0}, apnsConfig = defaultAPNSPushClientConfig, subsBatchSize = 900, inactiveClientExpiration = diff --git a/tests/NtfClient.hs b/tests/NtfClient.hs index f5b8a4b2e..bd8cee771 100644 --- a/tests/NtfClient.hs +++ b/tests/NtfClient.hs @@ -89,7 +89,7 @@ ntfServerCfg = clientQSize = 1, subQSize = 1, pushQSize = 1, - smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 1}, + smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 0}, apnsConfig = defaultAPNSPushClientConfig { apnsPort = apnsTestPort, From bd67844169d2206d8543c01e6ed966315115b0e3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 24 May 2024 14:26:02 +0100 Subject: [PATCH 084/107] 5.8.0.5 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 8cd924b4e..61f144eb1 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.4 +version: 5.8.0.5 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index c61e68624..55d8cd637 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.4 +version: 5.8.0.5 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From df35c50b99a1291aac3ff86ed71387bb4f03c984 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 25 May 2024 11:10:14 +0100 Subject: [PATCH 085/107] 5.7.5.0 --- CHANGELOG.md | 5 +++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a7bb5b3..8b521e0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 5.7.5 + +SMP agent: +- fail if non-unique connection IDs are passed to sendMessages (to prevent client errors and deadlocks). + # 5.7.4 SMP agent: diff --git a/package.yaml b/package.yaml index a1f4a225a..49dc27c0c 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.4.1 +version: 5.7.5.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 9bfa146c1..2e7d7bb53 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.4.1 +version: 5.7.5.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From ab7b350521ed732b1c9b686ebe9441ba30f30a91 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 27 May 2024 14:55:04 +0100 Subject: [PATCH 086/107] agent: prevent sending not-batched client commands once requests time out (#1173) --- src/Simplex/Messaging/Client.hs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index ecf4ee766..e33c91873 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -154,7 +154,7 @@ data PClient v err msg = PClient timeoutErrorCount :: TVar Int, clientCorrId :: TVar ChaChaDRG, sentCommands :: TMap CorrId (Request err msg), - sndQ :: TBQueue ByteString, + sndQ :: TBQueue (Maybe (TVar Bool), ByteString), rcvQ :: TBQueue (NonEmpty (SignedTransmission err msg)), msgQ :: Maybe (TBQueue (ServerTransmissionBatch v err msg)) } @@ -474,7 +474,11 @@ getProtocolClient g transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize `finally` disconnected c' send :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () - send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= void . tPutLog h + send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= sendPending + where + sendPending (Nothing, s) = send_ s + sendPending (Just pending, s) = whenM (readTVarIO pending) $ send_ s + send_ = void . tPutLog h receive :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO () receive ProtocolClient {client_ = PClient {rcvQ, lastReceived, timeoutErrorCount}} h = forever $ do @@ -965,29 +969,33 @@ sendBatch c@ProtocolClient {client_ = PClient {sndQ}} b = do pure [Response entityId $ Left $ PCETransportError e] TBTransmissions s n rs | n > 0 -> do - atomically $ writeTBQueue sndQ s + atomically $ writeTBQueue sndQ (Nothing, s) -- do not expire batched responses mapConcurrently (getResponse c Nothing) rs | otherwise -> pure [] TBTransmission s r -> do - atomically $ writeTBQueue sndQ s + atomically $ writeTBQueue sndQ (Nothing, s) (: []) <$> getResponse c Nothing r -- | Send Protocol command sendProtocolCommand :: forall v err msg. Protocol v err msg => ProtocolClient v err msg -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg sendProtocolCommand c = sendProtocolCommand_ c Nothing Nothing +-- Currently there is coupling - batch commands do not expire, and individually sent commands do. +-- This is to reflect the fact that we send subscriptions only as batches, and also because we do not track a separate timeout for the whole batch, so it is not obvious when should we expire it. +-- We could expire a batch of deletes, for example, either when the first response expires or when the last one does. +-- But a better solution is to process delayed delete responses. sendProtocolCommand_ :: forall v err msg. Protocol v err msg => ProtocolClient v err msg -> Maybe C.CbNonce -> Maybe Int -> Maybe C.APrivateAuthKey -> EntityId -> ProtoCommand msg -> ExceptT (ProtocolClientError err) IO msg sendProtocolCommand_ c@ProtocolClient {client_ = PClient {sndQ}, thParams = THandleParams {batch, blockSize}} nonce_ tOut pKey entId cmd = ExceptT $ uncurry sendRecv =<< mkTransmission_ c nonce_ (pKey, entId, cmd) where -- two separate "atomically" needed to avoid blocking sendRecv :: Either TransportError SentRawTransmission -> Request err msg -> IO (Either (ProtocolClientError err) msg) - sendRecv t_ r = case t_ of + sendRecv t_ r@Request {pending} = case t_ of Left e -> pure . Left $ PCETransportError e Right t | B.length s > blockSize - 2 -> pure . Left $ PCETransportError TELargeMsg | otherwise -> do - atomically $ writeTBQueue sndQ s + atomically $ writeTBQueue sndQ (Just pending, s) response <$> getResponse c tOut r where s From c8b2bb2ae1fe0e7dede6e57ba5807e062604f6a4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 May 2024 08:35:43 +0100 Subject: [PATCH 087/107] agent: process message sent in expired response to ACK (it will increase duplicates count) (#1175) --- src/Simplex/Messaging/Agent.hs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index bb6e52bad..6f0f52589 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -2102,7 +2102,6 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) Left e -> lift $ notifyErr connId e STResponse (Cmd SRecipient cmd) respOrErr -> withRcvConn entId $ \rq conn -> case cmd of - -- TODO process expired responses to ACK and DEL SMP.SUB -> case respOrErr of Right SMP.OK -> processSubOk rq upConnIds Right msg@SMP.MSG {} -> do @@ -2110,7 +2109,10 @@ processSMPTransmissions c@AgentClient {subQ} (tSess@(_, srv, _), _v, sessId, ts) processSMP rq conn (toConnData conn) msg Right r -> processSubErr rq $ unexpectedResponse r Left e -> unless (temporaryClientError e) $ processSubErr rq e -- timeout/network was already reported - _ -> pure () + SMP.ACK _ -> case respOrErr of + Right msg@SMP.MSG {} -> processSMP rq conn (toConnData conn) msg + _ -> pure () -- TODO process OK response to ACK + _ -> pure () -- TODO process expired response to DEL STResponse {} -> pure () -- TODO process expired responses to sent messages STUnexpectedError e -> do logServer "<--" c srv entId $ "error: " <> bshow e From 4a96dbf871dd28cf22a0a06b2e29cf45d1a439c7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 May 2024 09:38:47 +0100 Subject: [PATCH 088/107] server: preprocess proxy commands that will not be connecting to network to reduce concurrency, do not wait for destination relay responses before processing the next command (#1174) * server: preprocess proxy commands that will not be connecting to network to reduce concurrency * implementation * tests * increase proxy client concurrency * simplify * refactor * refactor2 * rename * refactor3 * fix 8.10.7 --- src/Simplex/Messaging/Client/Agent.hs | 16 ++- src/Simplex/Messaging/Server.hs | 144 ++++++++++++------------ src/Simplex/Messaging/Server/Env/STM.hs | 2 +- tests/AgentTests/EqInstances.hs | 5 + tests/SMPProxyTests.hs | 4 + 5 files changed, 99 insertions(+), 72 deletions(-) diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index bd878ada7..3b4a78cf5 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -45,7 +45,7 @@ import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport -import Simplex.Messaging.Util (catchAll_, ifM, toChunks, whenM, ($>>=)) +import Simplex.Messaging.Util (catchAll_, ifM, toChunks, whenM, ($>>=), (<$$>)) import System.Timeout (timeout) import UnliftIO (async) import qualified UnliftIO.Exception as E @@ -287,6 +287,20 @@ notify :: MonadIO m => SMPClientAgent -> SMPClientAgentEvent -> m () notify ca evt = atomically $ writeTBQueue (agentQ ca) evt {-# INLINE notify #-} +-- Returns already connected client for proxying messages or Nothing if client is absent, not connected yet or stores expired error. +-- If Nothing is return proxy will spawn a new thread to wait or to create another client connection to destination relay. +getConnectedSMPServerClient :: SMPClientAgent -> SMPServer -> IO (Maybe (Either SMPClientError (OwnServer, SMPClient))) +getConnectedSMPServerClient SMPClientAgent {smpClients} srv = + atomically (TM.lookup srv smpClients $>>= \v -> (v,) <$$> tryReadTMVar (sessionVar v)) -- Nothing: client is absent or not connected yet + $>>= \case + (_, Right r) -> pure $ Just $ Right r + (v, Left (e, ts_)) -> + pure ts_ $>>= \ts -> -- proxy will create a new connection if ts_ is Nothing + ifM + ((ts <) <$> liftIO getCurrentTime) -- error persistence interval period expired? + (Nothing <$ atomically (removeSessVar v srv smpClients)) -- proxy will create a new connection + (pure $ Just $ Left e) -- not expired, returning error + lookupSMPServerClient :: SMPClientAgent -> SessionId -> STM (Maybe (OwnServer, SMPClient)) lookupSMPServerClient SMPClientAgent {smpSessions} sessId = TM.lookup sessId smpSessions diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index c0ab7df8e..adf0b5df7 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -59,7 +59,7 @@ import Data.List (intercalate, mapAccumR) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M -import Data.Maybe (isNothing) +import Data.Maybe (catMaybes, fromMaybe, isNothing) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Data.Time.Clock (UTCTime (..), diffTimeToPicoseconds, getCurrentTime) @@ -70,8 +70,8 @@ import GHC.Stats (getRTSStats) import GHC.TypeLits (KnownNat) import Network.Socket (ServiceName, Socket, socketToHandle) import Simplex.Messaging.Agent.Lock -import Simplex.Messaging.Client (ProtocolClient (thParams), ProtocolClientError (..), forwardSMPMessage, smpProxyError, temporaryClientError) -import Simplex.Messaging.Client.Agent (OwnServer, SMPClientAgent (..), SMPClientAgentEvent (..), closeSMPClientAgent, getSMPServerClient'', isOwnServer, lookupSMPServerClient) +import Simplex.Messaging.Client (ProtocolClient (thParams), ProtocolClientError (..), SMPClient, SMPClientError, forwardSMPMessage, smpProxyError, temporaryClientError) +import Simplex.Messaging.Client.Agent (OwnServer, SMPClientAgent (..), SMPClientAgentEvent (..), closeSMPClientAgent, getSMPServerClient'', isOwnServer, lookupSMPServerClient, getConnectedSMPServerClient) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -103,7 +103,6 @@ import UnliftIO.IO import UnliftIO.STM #if MIN_VERSION_base(4,18,0) import Data.List (sort) -import Data.Maybe (fromMaybe) import GHC.Conc (listThreads, threadStatus) import GHC.Conc.Sync (threadLabel) #endif @@ -657,40 +656,33 @@ forkClient Client {endThreads, endThreadSeq} label action = do client :: THandleParams SMPVersion 'TServer -> Client -> Server -> M () client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessionId, procThreads} Server {subscribedQ, ntfSubscribedQ, notifiers} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " commands" - forever $ do - (proxied, rs) <- partitionEithers . L.toList <$> (mapM processCommand =<< atomically (readTBQueue rcvQ)) - forM_ (L.nonEmpty rs) reply - forM_ (L.nonEmpty proxied) $ \cmds -> mapM forkProxiedCmd cmds >>= mapM (atomically . takeTMVar) >>= reply + forever $ + atomically (readTBQueue rcvQ) + >>= mapM processCommand + >>= mapM_ reply . L.nonEmpty . catMaybes . L.toList where reply :: MonadIO m => NonEmpty (Transmission BrokerMsg) -> m () reply = atomically . writeTBQueue sndQ - forkProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (TMVar (Transmission BrokerMsg)) - forkProxiedCmd cmd = do - res <- newEmptyTMVarIO - bracket_ wait signal . forkClient clnt (B.unpack $ "client $" <> encode sessionId <> " proxy") $ - -- commands MUST be processed under a reasonable timeout or the client would halt - processProxiedCmd cmd >>= atomically . putTMVar res - pure res - where - wait = do - ServerConfig {serverClientConcurrency} <- asks config - atomically $ do - used <- readTVar procThreads - when (used >= serverClientConcurrency) retry - writeTVar procThreads $! used + 1 - signal = atomically $ modifyTVar' procThreads (\t -> t - 1) - processProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (Transmission BrokerMsg) - processProxiedCmd (corrId, sessId, command) = (corrId, sessId,) <$> case command of - PRXY srv auth -> ifM allowProxy getRelay (pure $ ERR $ PROXY BASIC_AUTH) + processProxiedCmd :: Transmission (Command 'ProxiedClient) -> M (Maybe (Transmission BrokerMsg)) + processProxiedCmd (corrId, sessId, command) = (corrId,sessId,) <$$> case command of + PRXY srv auth -> ifM allowProxy getRelay (pure $ Just $ ERR $ PROXY BASIC_AUTH) where allowProxy = do ServerConfig {allowSMPProxy, newQueueBasicAuth} <- asks config pure $ allowSMPProxy && maybe True ((== auth) . Just) newQueueBasicAuth getRelay = do + ProxyAgent {smpAgent = a} <- asks proxyAgent + liftIO (getConnectedSMPServerClient a srv) >>= \case + Just r -> Just <$> proxyServerResponse a r + Nothing -> + forkProxiedCmd $ + liftIO (runExceptT (getSMPServerClient'' a srv) `catch` (pure . Left . PCEIOError)) + >>= proxyServerResponse a + proxyServerResponse :: SMPClientAgent -> Either SMPClientError (OwnServer, SMPClient) -> M BrokerMsg + proxyServerResponse a smp_ = do ServerStats {pRelays, pRelaysOwn} <- asks serverStats let inc = mkIncProxyStats pRelays pRelaysOwn - ProxyAgent {smpAgent = a} <- asks proxyAgent - liftIO (runExceptT (getSMPServerClient'' a srv) `catch` (pure . Left . PCEIOError)) >>= \case + case smp_ of Right (own, smp) -> do inc own pRequests case proxyResp smp of @@ -704,7 +696,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi where proxyResp smp = let THandleParams {sessionId = srvSessId, thVersion, thServerVRange, thAuth} = thParams smp - in case compatibleVRange thServerVRange proxiedSMPRelayVRange of + in case compatibleVRange thServerVRange proxiedSMPRelayVRange of -- Cap the destination relay version range to prevent client version fingerprinting. -- See comment for proxiedSMPRelayVersion. Just (Compatible vr) | thVersion >= sendingProxySMPVersion -> case thAuth of @@ -718,54 +710,66 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi atomically (lookupSMPServerClient a sessId) >>= \case Just (own, smp) -> do inc own pRequests - if - | v >= sendingProxySMPVersion -> - liftIO (runExceptT (forwardSMPMessage smp corrId fwdV pubKey encBlock) `catch` (pure . Left . PCEIOError)) >>= \case - Right r -> PRES r <$ inc own pSuccesses - Left e -> case e of - PCEProtocolError {} -> ERR err <$ inc own pSuccesses - _ -> ERR err <$ inc own pErrorsOther - where - err = smpProxyError e - | otherwise -> ERR (transportErr TEVersion) <$ inc own pErrorsCompat + if v >= sendingProxySMPVersion + then forkProxiedCmd $ do + liftIO (runExceptT (forwardSMPMessage smp corrId fwdV pubKey encBlock) `catch` (pure . Left . PCEIOError)) >>= \case + Right r -> PRES r <$ inc own pSuccesses + Left e -> ERR (smpProxyError e) <$ case e of + PCEProtocolError {} -> inc own pSuccesses + _ -> inc own pErrorsOther + else Just (ERR $ transportErr TEVersion) <$ inc own pErrorsCompat where THandleParams {thVersion = v} = thParams smp - Nothing -> inc False pRequests >> inc False pErrorsConnect $> ERR (PROXY NO_SESSION) + Nothing -> inc False pRequests >> inc False pErrorsConnect $> Just (ERR $ PROXY NO_SESSION) + where + forkProxiedCmd :: M BrokerMsg -> M (Maybe BrokerMsg) + forkProxiedCmd cmdAction = do + bracket_ wait signal . forkClient clnt (B.unpack $ "client $" <> encode sessionId <> " proxy") $ do + -- commands MUST be processed under a reasonable timeout or the client would halt + cmdAction >>= \t -> reply [(corrId, sessId, t)] + pure Nothing + where + wait = do + ServerConfig {serverClientConcurrency} <- asks config + atomically $ do + used <- readTVar procThreads + when (used >= serverClientConcurrency) retry + writeTVar procThreads $! used + 1 + signal = atomically $ modifyTVar' procThreads (\t -> t - 1) transportErr :: TransportError -> ErrorType transportErr = PROXY . BROKER . TRANSPORT mkIncProxyStats :: MonadIO m => ProxyStats -> ProxyStats -> OwnServer -> (ProxyStats -> TVar Int) -> m () mkIncProxyStats ps psOwn = \own sel -> do atomically $ modifyTVar' (sel ps) (+ 1) when own $ atomically $ modifyTVar' (sel psOwn) (+ 1) - processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Either (Transmission (Command 'ProxiedClient)) (Transmission BrokerMsg)) - processCommand (qr_, (corrId, queueId, cmd)) = do - st <- asks queueStore - case cmd of - Cmd SProxiedClient command -> pure $ Left (corrId, queueId, command) - Cmd SSender command -> Right <$> case command of - SEND flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody - PING -> pure (corrId, "", PONG) - RFWD encBlock -> (corrId, "",) <$> processForwardedCommand encBlock - Cmd SNotifier NSUB -> Right <$> subscribeNotifications - Cmd SRecipient command -> - Right <$> case command of - NEW rKey dhKey auth subMode -> - ifM - allowNew - (createQueue st rKey dhKey subMode) - (pure (corrId, queueId, ERR AUTH)) - where - allowNew = do - ServerConfig {allowNewQueues, newQueueBasicAuth} <- asks config - pure $ allowNewQueues && maybe True ((== auth) . Just) newQueueBasicAuth - SUB -> withQueue (`subscribeQueue` queueId) - GET -> withQueue getMessage - ACK msgId -> withQueue (`acknowledgeMsg` msgId) - KEY sKey -> secureQueue_ st sKey - NKEY nKey dhKey -> addQueueNotifier_ st nKey dhKey - NDEL -> deleteQueueNotifier_ st - OFF -> suspendQueue_ st - DEL -> delQueueAndMsgs st + processCommand :: (Maybe QueueRec, Transmission Cmd) -> M (Maybe (Transmission BrokerMsg)) + processCommand (qr_, (corrId, queueId, cmd)) = case cmd of + Cmd SProxiedClient command -> processProxiedCmd (corrId, queueId, command) + Cmd SSender command -> Just <$> case command of + SEND flags msgBody -> withQueue $ \qr -> sendMessage qr flags msgBody + PING -> pure (corrId, "", PONG) + RFWD encBlock -> (corrId, "",) <$> processForwardedCommand encBlock + Cmd SNotifier NSUB -> Just <$> subscribeNotifications + Cmd SRecipient command -> do + st <- asks queueStore + Just <$> case command of + NEW rKey dhKey auth subMode -> + ifM + allowNew + (createQueue st rKey dhKey subMode) + (pure (corrId, queueId, ERR AUTH)) + where + allowNew = do + ServerConfig {allowNewQueues, newQueueBasicAuth} <- asks config + pure $ allowNewQueues && maybe True ((== auth) . Just) newQueueBasicAuth + SUB -> withQueue (`subscribeQueue` queueId) + GET -> withQueue getMessage + ACK msgId -> withQueue (`acknowledgeMsg` msgId) + KEY sKey -> secureQueue_ st sKey + NKEY nKey dhKey -> addQueueNotifier_ st nKey dhKey + NDEL -> deleteQueueNotifier_ st + OFF -> suspendQueue_ st + DEL -> delQueueAndMsgs st where createQueue :: QueueStore -> RcvPublicAuthKey -> RcvPublicDhKey -> SubscriptionMode -> M (Transmission BrokerMsg) createQueue st recipientKey dhKey subMode = time "NEW" $ do @@ -1036,7 +1040,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi Right t''@(_, (corrId', entId', cmd')) -> case cmd' of Cmd SSender SEND {} -> -- Left will not be returned by processCommand, as only SEND command is allowed - fromRight (corrId', entId', ERR INTERNAL) <$> lift (processCommand t'') + fromMaybe (corrId', entId', ERR INTERNAL) <$> lift (processCommand t'') _ -> pure (corrId', entId', ERR $ CMD PROHIBITED) -- encode response diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index 77adb94f4..f602c890b 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -104,7 +104,7 @@ defaultInactiveClientExpiration = } defaultProxyClientConcurrency :: Int -defaultProxyClientConcurrency = 16 +defaultProxyClientConcurrency = 32 data Env = Env { config :: ServerConfig, diff --git a/tests/AgentTests/EqInstances.hs b/tests/AgentTests/EqInstances.hs index aaaa2de51..a810247fe 100644 --- a/tests/AgentTests/EqInstances.hs +++ b/tests/AgentTests/EqInstances.hs @@ -6,6 +6,7 @@ module AgentTests.EqInstances where import Data.Type.Equality import Simplex.Messaging.Agent.Store +import Simplex.Messaging.Client (ProxiedRelay (..)) instance Eq SomeConn where SomeConn d c == SomeConn d' c' = case testEquality d d' of @@ -23,3 +24,7 @@ deriving instance Eq (StoredSndQueue q) deriving instance Eq (DBQueueId q) deriving instance Eq ClientNtfCreds + +deriving instance Show ProxiedRelay + +deriving instance Eq ProxiedRelay diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 748eb34e7..0c5792d7a 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -12,6 +12,7 @@ module SMPProxyTests where +import AgentTests.EqInstances () import AgentTests.FunctionalAPITests import Control.Logger.Simple import Control.Monad (forM, forM_, forever) @@ -150,10 +151,13 @@ deliverMessagesViaProxy proxyServ relayServ alg unsecuredMsgs securedMsgs = do QIK {rcvId, sndId, rcvPublicDhKey = srvDh} <- runExceptT' $ createSMPQueue rc (rPub, rPriv) rdhPub (Just "correct") SMSubscribe let dec = decryptMsgV3 $ C.dh' srvDh rdhPriv -- get proxy session + sess0 <- runExceptT' $ connectSMPProxiedRelay pc relayServ (Just "correct") sess <- runExceptT' $ connectSMPProxiedRelay pc relayServ (Just "correct") + sess0 `shouldBe` sess -- send via proxy to unsecured queue forM_ unsecuredMsgs $ \msg -> do runExceptT' (proxySMPMessage pc sess Nothing sndId noMsgFlags msg) `shouldReturn` Right () + runExceptT' (proxySMPMessage pc sess {prSessionId = "bad session"} Nothing sndId noMsgFlags msg) `shouldReturn` Left (ProxyProtocolError $ SMP.PROXY SMP.NO_SESSION) -- receive 1 (_tSess, _v, _sid, [(_entId, STEvent (Right (SMP.MSG RcvMessage {msgId, msgBody = EncRcvMsgBody encBody})))]) <- atomically $ readTBQueue msgQ dec msgId encBody `shouldBe` Right msg From 199f85ec62bcbadd11dc05bb97cce6556dafb384 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 May 2024 11:56:57 +0100 Subject: [PATCH 089/107] agent: send MWARN on QUOTA errors (#1176) * agent: send MWARN on QUOTA errors * fix tests --- src/Simplex/Messaging/Agent.hs | 6 +++++- tests/AgentTests.hs | 2 ++ tests/AgentTests/FunctionalAPITests.hs | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 6f0f52589..76d7d333d 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -1344,7 +1344,11 @@ runSmpQueueMsgDelivery c@AgentClient {subQ} ConnData {connId} sq (Worker {doWork AM_CONN_INFO_REPLY -> connError msgId NOT_AVAILABLE _ -> do expireTs <- addUTCTime (-quotaExceededTimeout) <$> liftIO getCurrentTime - if internalTs < expireTs then notifyDelMsgs msgId e expireTs else retrySndMsg RISlow + if internalTs < expireTs + then notifyDelMsgs msgId e expireTs + else do + notify $ MWARN (unId msgId) e + retrySndMsg RISlow SMP _ SMP.AUTH -> case msgType of AM_CONN_INFO -> connError msgId NOT_AVAILABLE AM_CONN_INFO_REPLY -> connError msgId NOT_AVAILABLE diff --git a/tests/AgentTests.hs b/tests/AgentTests.hs index 186c91e13..b200c3933 100644 --- a/tests/AgentTests.hs +++ b/tests/AgentTests.hs @@ -523,6 +523,7 @@ testMsgDeliveryQuotaExceeded _ alice bob = do (_, "bob", Right (MID mId)) <- alice #: (corrId, "bob", "SEND F :" <> msg) alice <#= \case ("", "bob", SENT m) -> m == mId; _ -> False (_, "bob", Right (MID _)) <- alice #: ("5", "bob", "SEND F :over quota") + alice <#= \case ("", "bob", MWARN _ (SMP _ QUOTA)) -> True; _ -> False alice #: ("1", "bob2", "SEND F :hello") #> ("1", "bob2", MID 4) -- if delivery is blocked it won't go further @@ -537,6 +538,7 @@ testResumeDeliveryQuotaExceeded _ alice bob = do (_, "bob", Right (MID mId)) <- alice #: (corrId, "bob", "SEND F :" <> msg) alice <#= \case ("", "bob", SENT m) -> m == mId; _ -> False ("5", "bob", Right (MID 8)) <- alice #: ("5", "bob", "SEND F :over quota") + alice <#= \case ("", "bob", MWARN 8 (SMP _ QUOTA)) -> True; _ -> False alice #:# "the last message not sent yet" bob <#= \case ("", "alice", Msg "message 1") -> True; _ -> False bob #: ("1", "alice", "ACK 4") #> ("1", "alice", OK) diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 250e95474..2b21ff3f7 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -148,6 +148,7 @@ pGet c = do CONNECT {} -> pGet c DISCONNECT {} -> pGet c ERR (BROKER _ NETWORK) -> pGet c + MWARN {} -> pGet c _ -> pure t pattern CONF :: ConfirmationId -> [SMPServer] -> ConnInfo -> ACommand 'Agent e From e55ec07fe215e6c1255e95c4113c88daba4f038e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 May 2024 15:32:41 +0100 Subject: [PATCH 090/107] server: log stats for QUOTA and other errors (#1177) * server: log stats for QUOTA errors * fix test * more stats * remove duplicate column --- src/Simplex/Messaging/Server.hs | 101 +++++++++++++++++--------- src/Simplex/Messaging/Server/Stats.hs | 66 +++++++++++++++-- tests/ServerTests.hs | 6 +- 3 files changed, 128 insertions(+), 45 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index adf0b5df7..0d209229c 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -228,7 +228,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do initialDelay <- (startAt -) . fromIntegral . (`div` 1000000_000000) . diffTimeToPicoseconds . utctDayTime <$> liftIO getCurrentTime liftIO $ putStrLn $ "server stats log enabled: " <> statsFilePath liftIO $ threadDelay' $ 1000000 * (initialDelay + if initialDelay < 0 then 86400 else 0) - ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, qCount, msgCount, pRelays, pRelaysOwn, pMsgFwds, pMsgFwdsOwn, pMsgFwdsRecv} <- asks serverStats + ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, qSub, qSubAuth, qSubDuplicate, qSubProhibited, msgSent, msgSentAuth, msgSentQuota, msgSentLarge, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, qCount, msgCount, pRelays, pRelaysOwn, pMsgFwds, pMsgFwdsOwn, pMsgFwdsRecv} <- asks serverStats let interval = 1000000 * logInterval forever $ do withFile statsFilePath AppendMode $ \h -> liftIO $ do @@ -240,7 +240,14 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do qDeletedAll' <- atomically $ swapTVar qDeletedAll 0 qDeletedNew' <- atomically $ swapTVar qDeletedNew 0 qDeletedSecured' <- atomically $ swapTVar qDeletedSecured 0 + qSub' <- atomically $ swapTVar qSub 0 + qSubAuth' <- atomically $ swapTVar qSubAuth 0 + qSubDuplicate' <- atomically $ swapTVar qSubDuplicate 0 + qSubProhibited' <- atomically $ swapTVar qSubProhibited 0 msgSent' <- atomically $ swapTVar msgSent 0 + msgSentAuth' <- atomically $ swapTVar msgSentAuth 0 + msgSentQuota' <- atomically $ swapTVar msgSentQuota 0 + msgSentLarge' <- atomically $ swapTVar msgSentLarge 0 msgRecv' <- atomically $ swapTVar msgRecv 0 msgExpired' <- atomically $ swapTVar msgExpired 0 ps <- atomically $ periodStatCounts activeQueues ts @@ -281,7 +288,15 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg} = do <> showProxyStats pRelaysOwn' <> showProxyStats pMsgFwds' <> showProxyStats pMsgFwdsOwn' - <> [show pMsgFwdsRecv'] + <> [ show pMsgFwdsRecv', + show qSub', + show qSubAuth', + show qSubDuplicate', + show qSubProhibited', + show msgSentAuth', + show msgSentQuota', + show msgSentLarge' + ] ) liftIO $ threadDelay' interval where @@ -504,19 +519,25 @@ receive h@THandle {params = THandleParams {thAuth}} Client {rcvQ, sndQ, rcvActiv forever $ do ts <- L.toList <$> liftIO (tGet h) atomically . writeTVar rcvActiveAt =<< liftIO getSystemTime - (errs, cmds) <- partitionEithers <$> mapM cmdAction ts + stats <- asks serverStats + (errs, cmds) <- partitionEithers <$> mapM (cmdAction stats) ts write sndQ errs write rcvQ cmds where - cmdAction :: SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) - cmdAction (tAuth, authorized, (corrId, entId, cmdOrError)) = + cmdAction :: ServerStats -> SignedTransmission ErrorType Cmd -> M (Either (Transmission BrokerMsg) (Maybe QueueRec, Transmission Cmd)) + cmdAction stats (tAuth, authorized, (corrId, entId, cmdOrError)) = case cmdOrError of Left e -> pure $ Left (corrId, entId, ERR e) - Right cmd -> verified <$> verifyTransmission ((,C.cbNonce (bs corrId)) <$> thAuth) tAuth authorized entId cmd + Right cmd -> verified =<< verifyTransmission ((,C.cbNonce (bs corrId)) <$> thAuth) tAuth authorized entId cmd where verified = \case - VRVerified qr -> Right (qr, (corrId, entId, cmd)) - VRFailed -> Left (corrId, entId, ERR AUTH) + VRVerified qr -> pure $ Right (qr, (corrId, entId, cmd)) + VRFailed -> do + case cmd of + Cmd _ SEND {} -> atomically $ modifyTVar' (msgSentAuth stats) (+ 1) + Cmd _ SUB -> atomically $ modifyTVar' (qSubAuth stats) (+ 1) + _ -> pure () + pure $ Left (corrId, entId, ERR AUTH) write q = mapM_ (atomically . writeTBQueue q) . L.nonEmpty send :: Transport c => MVar (THandleSMP c 'TServer) -> Client -> IO () @@ -856,15 +877,19 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi subscribeQueue :: QueueRec -> RecipientId -> M (Transmission BrokerMsg) subscribeQueue qr rId = do + stats <- asks serverStats atomically (TM.lookup rId subscriptions) >>= \case - Nothing -> + Nothing -> do + atomically $ modifyTVar' (qSub stats) (+ 1) newSub >>= deliver Just sub -> readTVarIO sub >>= \case - Sub {subThread = ProhibitSub} -> + Sub {subThread = ProhibitSub} -> do -- cannot use SUB in the same connection where GET was used + atomically $ modifyTVar' (qSubProhibited stats) (+ 1) pure (corrId, rId, ERR $ CMD PROHIBITED) - s -> + s -> do + atomically $ modifyTVar' (qSubDuplicate stats) (+ 1) atomically (tryTakeTMVar $ delivered s) >> deliver sub where newSub :: M (TVar Sub) @@ -958,29 +983,37 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi sendMessage :: QueueRec -> MsgFlags -> MsgBody -> M (Transmission BrokerMsg) sendMessage qr msgFlags msgBody - | B.length msgBody > maxMessageLength thVersion = pure $ err LARGE_MSG - | otherwise = case status qr of - QueueOff -> return $ err AUTH - QueueActive -> - case C.maxLenBS msgBody of - Left _ -> pure $ err LARGE_MSG - Right body -> do - msg_ <- time "SEND" $ do - q <- getStoreMsgQueue "SEND" $ recipientId qr - expireMessages q - atomically . writeMsg q =<< mkMessage body - case msg_ of - Nothing -> pure $ err QUOTA - Just msg -> time "SEND ok" $ do - stats <- asks serverStats - when (notification msgFlags) $ do - atomically . trySendNotification msg =<< asks random - atomically $ modifyTVar' (msgSentNtf stats) (+ 1) - atomically $ updatePeriodStats (activeQueuesNtf stats) (recipientId qr) - atomically $ modifyTVar' (msgSent stats) (+ 1) - atomically $ modifyTVar' (msgCount stats) (+ 1) - atomically $ updatePeriodStats (activeQueues stats) (recipientId qr) - pure ok + | B.length msgBody > maxMessageLength thVersion = do + stats <- asks serverStats + atomically $ modifyTVar' (msgSentLarge stats) (+ 1) + pure $ err LARGE_MSG + | otherwise = do + stats <- asks serverStats + case status qr of + QueueOff -> do + atomically $ modifyTVar' (msgSentAuth stats) (+ 1) + pure $ err AUTH + QueueActive -> + case C.maxLenBS msgBody of + Left _ -> pure $ err LARGE_MSG + Right body -> do + msg_ <- time "SEND" $ do + q <- getStoreMsgQueue "SEND" $ recipientId qr + expireMessages q + atomically . writeMsg q =<< mkMessage body + case msg_ of + Nothing -> do + atomically $ modifyTVar' (msgSentQuota stats) (+ 1) + pure $ err QUOTA + Just msg -> time "SEND ok" $ do + when (notification msgFlags) $ do + atomically . trySendNotification msg =<< asks random + atomically $ modifyTVar' (msgSentNtf stats) (+ 1) + atomically $ updatePeriodStats (activeQueuesNtf stats) (recipientId qr) + atomically $ modifyTVar' (msgSent stats) (+ 1) + atomically $ modifyTVar' (msgCount stats) (+ 1) + atomically $ updatePeriodStats (activeQueues stats) (recipientId qr) + pure ok where THandleParams {thVersion} = thParams' mkMessage :: C.MaxLenBS MaxMessageLen -> M Message diff --git a/src/Simplex/Messaging/Server/Stats.hs b/src/Simplex/Messaging/Server/Stats.hs index d8935b44b..880791c3d 100644 --- a/src/Simplex/Messaging/Server/Stats.hs +++ b/src/Simplex/Messaging/Server/Stats.hs @@ -26,7 +26,14 @@ data ServerStats = ServerStats qDeletedAll :: TVar Int, qDeletedNew :: TVar Int, qDeletedSecured :: TVar Int, + qSub :: TVar Int, + qSubAuth :: TVar Int, + qSubDuplicate :: TVar Int, + qSubProhibited :: TVar Int, msgSent :: TVar Int, + msgSentAuth :: TVar Int, + msgSentQuota :: TVar Int, + msgSentLarge :: TVar Int, msgRecv :: TVar Int, msgExpired :: TVar Int, activeQueues :: PeriodStats RecipientId, @@ -49,7 +56,14 @@ data ServerStatsData = ServerStatsData _qDeletedAll :: Int, _qDeletedNew :: Int, _qDeletedSecured :: Int, + _qSub :: Int, + _qSubAuth :: Int, + _qSubDuplicate :: Int, + _qSubProhibited :: Int, _msgSent :: Int, + _msgSentAuth :: Int, + _msgSentQuota :: Int, + _msgSentLarge :: Int, _msgRecv :: Int, _msgExpired :: Int, _activeQueues :: PeriodStatsData RecipientId, @@ -74,7 +88,14 @@ newServerStats ts = do qDeletedAll <- newTVar 0 qDeletedNew <- newTVar 0 qDeletedSecured <- newTVar 0 + qSub <- newTVar 0 + qSubAuth <- newTVar 0 + qSubDuplicate <- newTVar 0 + qSubProhibited <- newTVar 0 msgSent <- newTVar 0 + msgSentAuth <- newTVar 0 + msgSentQuota <- newTVar 0 + msgSentLarge <- newTVar 0 msgRecv <- newTVar 0 msgExpired <- newTVar 0 activeQueues <- newPeriodStats @@ -88,7 +109,7 @@ newServerStats ts = do pMsgFwdsRecv <- newTVar 0 qCount <- newTVar 0 msgCount <- newTVar 0 - pure ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, msgSent, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, pRelays, pRelaysOwn, pMsgFwds, pMsgFwdsOwn, pMsgFwdsRecv, qCount, msgCount} + pure ServerStats {fromTime, qCreated, qSecured, qDeletedAll, qDeletedNew, qDeletedSecured, qSub, qSubAuth, qSubDuplicate, qSubProhibited, msgSent, msgSentAuth, msgSentQuota, msgSentLarge, msgRecv, msgExpired, activeQueues, msgSentNtf, msgRecvNtf, activeQueuesNtf, pRelays, pRelaysOwn, pMsgFwds, pMsgFwdsOwn, pMsgFwdsRecv, qCount, msgCount} getServerStatsData :: ServerStats -> STM ServerStatsData getServerStatsData s = do @@ -98,7 +119,14 @@ getServerStatsData s = do _qDeletedAll <- readTVar $ qDeletedAll s _qDeletedNew <- readTVar $ qDeletedNew s _qDeletedSecured <- readTVar $ qDeletedSecured s + _qSub <- readTVar $ qSub s + _qSubAuth <- readTVar $ qSubAuth s + _qSubDuplicate <- readTVar $ qSubDuplicate s + _qSubProhibited <- readTVar $ qSubProhibited s _msgSent <- readTVar $ msgSent s + _msgSentAuth <- readTVar $ msgSentAuth s + _msgSentQuota <- readTVar $ msgSentQuota s + _msgSentLarge <- readTVar $ msgSentLarge s _msgRecv <- readTVar $ msgRecv s _msgExpired <- readTVar $ msgExpired s _activeQueues <- getPeriodStatsData $ activeQueues s @@ -112,7 +140,7 @@ getServerStatsData s = do _pMsgFwdsRecv <- readTVar $ pMsgFwdsRecv s _qCount <- readTVar $ qCount s _msgCount <- readTVar $ msgCount s - pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _activeQueues, _msgSentNtf, _msgRecvNtf, _activeQueuesNtf, _pRelays, _pRelaysOwn, _pMsgFwds, _pMsgFwdsOwn, _pMsgFwdsRecv, _qCount, _msgCount} + pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _qSub, _qSubAuth, _qSubDuplicate, _qSubProhibited, _msgSent, _msgSentAuth, _msgSentQuota, _msgSentLarge, _msgRecv, _msgExpired, _activeQueues, _msgSentNtf, _msgRecvNtf, _activeQueuesNtf, _pRelays, _pRelaysOwn, _pMsgFwds, _pMsgFwdsOwn, _pMsgFwdsRecv, _qCount, _msgCount} setServerStats :: ServerStats -> ServerStatsData -> STM () setServerStats s d = do @@ -122,7 +150,14 @@ setServerStats s d = do writeTVar (qDeletedAll s) $! _qDeletedAll d writeTVar (qDeletedNew s) $! _qDeletedNew d writeTVar (qDeletedSecured s) $! _qDeletedSecured d + writeTVar (qSub s) $! _qSub d + writeTVar (qSubAuth s) $! _qSubAuth d + writeTVar (qSubDuplicate s) $! _qSubDuplicate d + writeTVar (qSubProhibited s) $! _qSubProhibited d writeTVar (msgSent s) $! _msgSent d + writeTVar (msgSentAuth s) $! _msgSentAuth d + writeTVar (msgSentQuota s) $! _msgSentQuota d + writeTVar (msgSentLarge s) $! _msgSentLarge d writeTVar (msgRecv s) $! _msgRecv d writeTVar (msgExpired s) $! _msgExpired d setPeriodStats (activeQueues s) (_activeQueues d) @@ -147,7 +182,14 @@ instance StrEncoding ServerStatsData where "qDeletedNew=" <> strEncode (_qDeletedNew d), "qDeletedSecured=" <> strEncode (_qDeletedSecured d), "qCount=" <> strEncode (_qCount d), + "qSub=" <> strEncode (_qSub d), + "qSubAuth=" <> strEncode (_qSubAuth d), + "qSubDuplicate=" <> strEncode (_qSubDuplicate d), + "qSubProhibited=" <> strEncode (_qSubProhibited d), "msgSent=" <> strEncode (_msgSent d), + "msgSentAuth=" <> strEncode (_msgSentAuth d), + "msgSentQuota=" <> strEncode (_msgSentQuota d), + "msgSentLarge=" <> strEncode (_msgSentLarge d), "msgRecv=" <> strEncode (_msgRecv d), "msgExpired=" <> strEncode (_msgExpired d), "msgSentNtf=" <> strEncode (_msgSentNtf d), @@ -173,12 +215,19 @@ instance StrEncoding ServerStatsData where (_qDeletedAll, _qDeletedNew, _qDeletedSecured) <- (,0,0) <$> ("qDeleted=" *> strP <* A.endOfLine) <|> ((,,) <$> ("qDeletedAll=" *> strP <* A.endOfLine) <*> ("qDeletedNew=" *> strP <* A.endOfLine) <*> ("qDeletedSecured=" *> strP <* A.endOfLine)) - _qCount <- "qCount=" *> strP <* A.endOfLine <|> pure 0 + _qCount <- opt "qCount=" + _qSub <- opt "qSub=" + _qSubAuth <- opt "qSubAuth=" + _qSubDuplicate <- opt "qSubDuplicate=" + _qSubProhibited <- opt "qSubProhibited=" _msgSent <- "msgSent=" *> strP <* A.endOfLine + _msgSentAuth <- opt "msgSentAuth=" + _msgSentQuota <- opt "msgSentQuota=" + _msgSentLarge <- opt "msgSentLarge=" _msgRecv <- "msgRecv=" *> strP <* A.endOfLine - _msgExpired <- "msgExpired=" *> strP <* A.endOfLine <|> pure 0 - _msgSentNtf <- "msgSentNtf=" *> strP <* A.endOfLine <|> pure 0 - _msgRecvNtf <- "msgRecvNtf=" *> strP <* A.endOfLine <|> pure 0 + _msgExpired <- opt "msgExpired=" + _msgSentNtf <- opt "msgSentNtf=" + _msgRecvNtf <- opt "msgRecvNtf=" _activeQueues <- optional ("activeQueues:" <* A.endOfLine) >>= \case Just _ -> strP <* optional A.endOfLine @@ -195,9 +244,10 @@ instance StrEncoding ServerStatsData where _pRelaysOwn <- proxyStatsP "pRelaysOwn:" _pMsgFwds <- proxyStatsP "pMsgFwds:" _pMsgFwdsOwn <- proxyStatsP "pMsgFwdsOwn:" - _pMsgFwdsRecv <- "pMsgFwdsRecv=" *> strP <* A.endOfLine <|> pure 0 - pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _msgSent, _msgRecv, _msgExpired, _msgSentNtf, _msgRecvNtf, _activeQueues, _activeQueuesNtf, _pRelays, _pRelaysOwn, _pMsgFwds, _pMsgFwdsOwn, _pMsgFwdsRecv, _qCount, _msgCount = 0} + _pMsgFwdsRecv <- opt "pMsgFwdsRecv=" + pure ServerStatsData {_fromTime, _qCreated, _qSecured, _qDeletedAll, _qDeletedNew, _qDeletedSecured, _qSub, _qSubAuth, _qSubDuplicate, _qSubProhibited, _msgSent, _msgSentAuth, _msgSentQuota, _msgSentLarge, _msgRecv, _msgExpired, _msgSentNtf, _msgRecvNtf, _activeQueues, _activeQueuesNtf, _pRelays, _pRelaysOwn, _pMsgFwds, _pMsgFwdsOwn, _pMsgFwdsRecv, _qCount, _msgCount = 0} where + opt s = A.string s *> strP <* A.endOfLine <|> pure 0 proxyStatsP key = optional (A.string key >> A.endOfLine) >>= \case Just _ -> strP <* optional A.endOfLine diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index b0c90ca96..e2ca278d9 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -608,7 +608,7 @@ testRestoreMessages at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 2 logSize testStoreMsgsFile `shouldReturn` 5 - logSize testServerStatsBackupFile `shouldReturn` 45 + logSize testServerStatsBackupFile `shouldReturn` 52 Right stats1 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats1 [rId] 5 1 @@ -626,7 +626,7 @@ testRestoreMessages at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 1 -- the last message is not removed because it was not ACK'd logSize testStoreMsgsFile `shouldReturn` 3 - logSize testServerStatsBackupFile `shouldReturn` 45 + logSize testServerStatsBackupFile `shouldReturn` 52 Right stats2 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats2 [rId] 5 3 @@ -645,7 +645,7 @@ testRestoreMessages at@(ATransport t) = logSize testStoreLogFile `shouldReturn` 1 logSize testStoreMsgsFile `shouldReturn` 0 - logSize testServerStatsBackupFile `shouldReturn` 45 + logSize testServerStatsBackupFile `shouldReturn` 52 Right stats3 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats3 [rId] 5 5 From ee052a454ee943cb54a805247d4df13e102ddb67 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 May 2024 15:33:55 +0100 Subject: [PATCH 091/107] 5.8.0.6 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 61f144eb1..7f548c9a4 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.5 +version: 5.8.0.6 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 55d8cd637..65a2c24be 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.5 +version: 5.8.0.6 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 63f5e76f9cb33742d3c0c812af0e342dffafca0c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 29 May 2024 08:06:01 +0100 Subject: [PATCH 092/107] agent: treat absent proxy session as a temporary error to retry sending (#1178) * agent: treat absent proxy session as a temporary error to retry sending * enable all tests --- src/Simplex/Messaging/Agent/Client.hs | 22 ++++++++- tests/SMPClient.hs | 3 ++ tests/SMPProxyTests.hs | 65 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 37f659c9f..a99d957e3 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -1039,17 +1039,34 @@ sendOrProxySMPMessage c userId destSrv cmdStr spKey_ senderId msgFlags msg = do SPFAllowProtected -> ipAddressProtected cfg destSrv SPFProhibit -> False unknownServer = maybe True (all ((destSrv /=) . protoServer)) <$> TM.lookup userId (userServers c) - sendViaProxy destSess = do + sendViaProxy destSess@(_, _, qId) = do r <- tryAgentError . withProxySession c destSess senderId ("PFWD " <> cmdStr) $ \(SMPConnectedClient smp _, proxySess) -> do liftClient SMP (clientServer smp) (proxySMPMessage smp proxySess spKey_ senderId msgFlags msg) >>= \case Right () -> pure . Just $ protocolClientServer' smp - Left proxyErr -> + Left proxyErr -> do + case proxyErr of + (ProxyProtocolError (SMP.PROXY SMP.NO_SESSION)) -> atomically deleteRelaySession + _ -> pure () throwE PROXY { proxyServer = protocolClientServer smp, relayServer = B.unpack $ strEncode destSrv, proxyErr } + where + -- checks that the current proxied relay session is the same one that was used to send the message and removes it + deleteRelaySession = + ( TM.lookup destSess (smpProxiedRelays c) + $>>= \(ProtoServerWithAuth srv _) -> tryReadSessVar (userId, srv, qId) (smpClients c) + ) + >>= \case + Just (Right (SMPConnectedClient smp' prs)) | sameClient smp' -> + tryReadSessVar destSrv prs >>= \case + Just (Right proxySess') | sameProxiedRelay proxySess' -> TM.delete destSrv prs + _ -> pure () + _ -> pure () + sameClient smp' = sessionId (thParams smp) == sessionId (thParams smp') + sameProxiedRelay proxySess' = prSessionId proxySess == prSessionId proxySess' case r of Right r' -> pure r' Left e @@ -1288,6 +1305,7 @@ temporaryAgentError = \case BROKER _ e -> tempBrokerError e SMP _ (SMP.PROXY (SMP.BROKER e)) -> tempBrokerError e PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER e))) -> tempBrokerError e + PROXY _ _ (ProxyProtocolError (SMP.PROXY SMP.NO_SESSION)) -> True INACTIVE -> True _ -> False where diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 1828ffae6..f8c0e22c1 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -59,6 +59,9 @@ testStoreLogFile2 = "tests/tmp/smp-server-store.log.2" testStoreMsgsFile :: FilePath testStoreMsgsFile = "tests/tmp/smp-server-messages.log" +testStoreMsgsFile2 :: FilePath +testStoreMsgsFile2 = "tests/tmp/smp-server-messages.log.2" + testServerStatsBackupFile :: FilePath testServerStatsBackupFile = "tests/tmp/smp-server-stats.log" diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 0c5792d7a..036c8b203 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -14,6 +14,7 @@ module SMPProxyTests where import AgentTests.EqInstances () import AgentTests.FunctionalAPITests +import Control.Concurrent (ThreadId) import Control.Logger.Simple import Control.Monad (forM, forM_, forever) import Control.Monad.Trans.Except (ExceptT, runExceptT) @@ -113,6 +114,8 @@ smpProxyTests = do agentDeliverMessageViaProxy ([srv1], SPMUnknown, False) ([srv2], SPMUnknown, False) C.SEd448 "hello 1" "hello 2" it "fails when fallback is prohibited" . twoServers_ proxyCfg cfgV7 $ agentViaProxyVersionError + it "retries sending when destination or proxy relay is offline" $ + agentViaProxyRetryOffline describe "stress test 1k" $ do let deliver nAgents nMsgs = agentDeliverMessagesViaProxyConc (replicate nAgents [srv1]) (map bshow [1 :: Int .. nMsgs]) it "2 agents, 250 messages" . oneServer $ deliver 2 250 @@ -290,6 +293,68 @@ agentViaProxyVersionError = where servers srvs = (initAgentServersProxy SPMUnknown SPFProhibit) {smp = userServers $ L.map noAuthSrv srvs} +agentViaProxyRetryOffline :: IO () +agentViaProxyRetryOffline = do + let srv1 = SMPServer testHost testPort testKeyHash + srv2 = SMPServer testHost testPort2 testKeyHash + msg1 = "hello 1" + msg2 = "hello 2" + aProxySrv = Just srv1 + bProxySrv = Just srv2 + withAgent 1 aCfg (servers srv1) testDB $ \alice -> + withAgent 2 aCfg (servers srv2) testDB2 $ \bob -> do + let pqEnc = CR.PQEncOn + withServer $ \_ -> do + (aliceId, bobId) <- withServer2 $ \_ -> runRight $ do + (bobId, qInfo) <- A.createConnection alice 1 True SCMInvitation Nothing (CR.IKNoPQ PQSupportOn) SMSubscribe + aliceId <- A.joinConnection bob 1 Nothing True qInfo "bob's connInfo" PQSupportOn SMSubscribe + ("", _, A.CONF confId pqSup' _ "bob's connInfo") <- get alice + liftIO $ pqSup' `shouldBe` PQSupportOn + allowConnection alice bobId confId "alice's connInfo" + get alice ##> ("", bobId, A.CON pqEnc) + get bob ##> ("", aliceId, A.INFO PQSupportOn "alice's connInfo") + get bob ##> ("", aliceId, A.CON pqEnc) + 1 <- msgId <$> A.sendMessage alice bobId pqEnc noMsgFlags msg1 + get alice ##> ("", bobId, A.SENT (baseId + 1) aProxySrv) + get bob =##> \case ("", c, Msg' _ pq msg1') -> c == aliceId && pq == pqEnc && msg1 == msg1'; _ -> False + ackMessage bob aliceId (baseId + 1) Nothing + 2 <- msgId <$> A.sendMessage bob aliceId pqEnc noMsgFlags msg2 + get bob ##> ("", aliceId, A.SENT (baseId + 2) bProxySrv) + get alice =##> \case ("", c, Msg' _ pq msg2') -> c == bobId && pq == pqEnc && msg2 == msg2'; _ -> False + ackMessage alice bobId (baseId + 2) Nothing + pure (aliceId, bobId) + runRight_ $ do + -- destination relay down + 3 <- msgId <$> A.sendMessage alice bobId pqEnc noMsgFlags msg1 + bob `down` aliceId + withServer2 $ \_ -> runRight_ $ do + bob `up` aliceId + get alice ##> ("", bobId, A.SENT (baseId + 3) aProxySrv) + get bob =##> \case ("", c, Msg' _ pq msg1') -> c == aliceId && pq == pqEnc && msg1 == msg1'; _ -> False + ackMessage bob aliceId (baseId + 3) Nothing + runRight_ $ do + -- proxy relay down + 4 <- msgId <$> A.sendMessage bob aliceId pqEnc noMsgFlags msg2 + bob `down` aliceId + withServer2 $ \_ -> runRight_ $ do + bob `up` aliceId + get bob ##> ("", aliceId, A.SENT (baseId + 4) bProxySrv) + get alice =##> \case ("", c, Msg' _ pq msg2') -> c == bobId && pq == pqEnc && msg2 == msg2'; _ -> False + ackMessage alice bobId (baseId + 4) Nothing + where + withServer :: (ThreadId -> IO a) -> IO a + withServer = withServer_ testStoreLogFile testStoreMsgsFile testPort + withServer2 :: (ThreadId -> IO a) -> IO a + withServer2 = withServer_ testStoreLogFile2 testStoreMsgsFile2 testPort2 + withServer_ storeLog storeMsgs port = + withSmpServerConfigOn (transport @TLS) proxyCfg {storeLogFile = Just storeLog, storeMsgsFile = Just storeMsgs} port + a `up` cId = nGet a =##> \case ("", "", UP _ [c]) -> c == cId; _ -> False + a `down` cId = nGet a =##> \case ("", "", DOWN _ [c]) -> c == cId; _ -> False + aCfg = agentProxyCfg {messageRetryInterval = fastMessageRetryInterval} + baseId = 3 + msgId = subtract baseId . fst + servers srv = (initAgentServersProxy SPMAlways SPFProhibit) {smp = userServers $ L.map noAuthSrv [srv]} + testNoProxy :: IO () testNoProxy = do withSmpServerConfigOn (transport @TLS) cfg testPort2 $ \_ -> do From 0f663bd569f5d282fc13cd969391f30d873d73a0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 29 May 2024 08:09:27 +0100 Subject: [PATCH 093/107] 5.8.0.7 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 7f548c9a4..02095d95f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.6 +version: 5.8.0.7 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 65a2c24be..890ecf1e8 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.6 +version: 5.8.0.7 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 5c2c88315a0d702b53094b7678cc68d18a8095a9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 29 May 2024 11:30:42 +0100 Subject: [PATCH 094/107] SMP server information (#1072) * SMP server information * fix tests * country codes * smp-server: serve contact and link pages from static files (#1084) * smp-server: serve contact and link pages from static files * generate index * use params from ini * render using ServerInformation * tweak templates * update * fix some html * smp-server: fix layout (#1097) * smp-server: fix layout * port fixes to link page --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> * update server information page --------- Co-authored-by: Evgeny Poberezkin Co-authored-by: M. Sarmad Qadeer * update server info * web: improve server info page design (#1166) * web: improve server info page design * web: fix font errors & some tags * web: improve contact & invitation page layout and header * update * remove unused files/css * cleanup * fix link page * remove unused font links --------- Co-authored-by: Evgeny Poberezkin * show contact address as is --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Co-authored-by: M. Sarmad Qadeer --- apps/smp-server/Main.hs | 5 +- apps/smp-server/static/contact/index.html | 1 + apps/smp-server/static/index.html | 514 +++ apps/smp-server/static/invitation/index.html | 1 + apps/smp-server/static/link.html | 527 +++ apps/smp-server/static/media/GilroyBold.woff2 | Bin 0 -> 30884 bytes .../smp-server/static/media/GilroyLight.woff2 | Bin 0 -> 29968 bytes .../static/media/GilroyMedium.woff2 | Bin 0 -> 30648 bytes .../static/media/GilroyRegular.woff2 | Bin 0 -> 29136 bytes .../static/media/GilroyRegularItalic.woff2 | Bin 0 -> 31452 bytes apps/smp-server/static/media/apk_icon.png | Bin 0 -> 18130 bytes apps/smp-server/static/media/apple_store.svg | 26 + apps/smp-server/static/media/contact.js | 66 + .../static/media/contact_page_mobile.png | Bin 0 -> 295874 bytes apps/smp-server/static/media/f_droid.svg | 372 ++ apps/smp-server/static/media/favicon.ico | Bin 0 -> 1150 bytes apps/smp-server/static/media/google_play.svg | 39 + apps/smp-server/static/media/logo-dark.png | Bin 0 -> 7383 bytes apps/smp-server/static/media/logo-light.png | Bin 0 -> 8262 bytes .../static/media/logo-symbol-dark.svg | 10 + .../static/media/logo-symbol-light.svg | 15 + apps/smp-server/static/media/moon.svg | 3 + apps/smp-server/static/media/qrcode.js | 1 + apps/smp-server/static/media/script.js | 39 + apps/smp-server/static/media/style.css | 410 +++ apps/smp-server/static/media/sun.svg | 11 + .../static/media/swiper-bundle.min.css | 13 + .../static/media/swiper-bundle.min.js | 14 + apps/smp-server/static/media/tailwind.css | 3058 +++++++++++++++++ apps/smp-server/static/media/testflight.png | Bin 0 -> 18151 bytes apps/smp-server/web/Static.hs | 176 + apps/smp-server/web/Static/Embedded.hs | 15 + package.yaml | 10 +- rfcs/2024-03-20-server-metadata.md | 33 +- simplexmq.cabal | 35 + src/Simplex/FileTransfer/Server/Main.hs | 19 +- src/Simplex/Messaging/Agent/Protocol.hs | 59 +- .../Messaging/Notifications/Server/Main.hs | 12 +- src/Simplex/Messaging/Server/CLI.hs | 2 +- src/Simplex/Messaging/Server/Env/STM.hs | 30 +- src/Simplex/Messaging/Server/Information.hs | 76 + src/Simplex/Messaging/Server/Main.hs | 279 +- tests/CLITests.hs | 2 +- tests/SMPClient.hs | 3 +- 44 files changed, 5780 insertions(+), 96 deletions(-) create mode 120000 apps/smp-server/static/contact/index.html create mode 100644 apps/smp-server/static/index.html create mode 120000 apps/smp-server/static/invitation/index.html create mode 100644 apps/smp-server/static/link.html create mode 100644 apps/smp-server/static/media/GilroyBold.woff2 create mode 100644 apps/smp-server/static/media/GilroyLight.woff2 create mode 100644 apps/smp-server/static/media/GilroyMedium.woff2 create mode 100644 apps/smp-server/static/media/GilroyRegular.woff2 create mode 100644 apps/smp-server/static/media/GilroyRegularItalic.woff2 create mode 100644 apps/smp-server/static/media/apk_icon.png create mode 100644 apps/smp-server/static/media/apple_store.svg create mode 100644 apps/smp-server/static/media/contact.js create mode 100644 apps/smp-server/static/media/contact_page_mobile.png create mode 100644 apps/smp-server/static/media/f_droid.svg create mode 100644 apps/smp-server/static/media/favicon.ico create mode 100644 apps/smp-server/static/media/google_play.svg create mode 100644 apps/smp-server/static/media/logo-dark.png create mode 100644 apps/smp-server/static/media/logo-light.png create mode 100644 apps/smp-server/static/media/logo-symbol-dark.svg create mode 100644 apps/smp-server/static/media/logo-symbol-light.svg create mode 100644 apps/smp-server/static/media/moon.svg create mode 100644 apps/smp-server/static/media/qrcode.js create mode 100644 apps/smp-server/static/media/script.js create mode 100644 apps/smp-server/static/media/style.css create mode 100644 apps/smp-server/static/media/sun.svg create mode 100644 apps/smp-server/static/media/swiper-bundle.min.css create mode 100644 apps/smp-server/static/media/swiper-bundle.min.js create mode 100644 apps/smp-server/static/media/tailwind.css create mode 100644 apps/smp-server/static/media/testflight.png create mode 100644 apps/smp-server/web/Static.hs create mode 100644 apps/smp-server/web/Static/Embedded.hs create mode 100644 src/Simplex/Messaging/Server/Information.hs diff --git a/apps/smp-server/Main.hs b/apps/smp-server/Main.hs index d5cc5e732..d0de4b4f1 100644 --- a/apps/smp-server/Main.hs +++ b/apps/smp-server/Main.hs @@ -1,10 +1,9 @@ -{-# LANGUAGE LambdaCase #-} - module Main where import Control.Logger.Simple import Simplex.Messaging.Server.CLI (getEnvPath) import Simplex.Messaging.Server.Main +import qualified Static defaultCfgPath :: FilePath defaultCfgPath = "/etc/opt/simplex" @@ -20,4 +19,4 @@ main = do setLogLevel LogDebug cfgPath <- getEnvPath "SMP_SERVER_CFG_PATH" defaultCfgPath logPath <- getEnvPath "SMP_SERVER_LOG_PATH" defaultLogPath - withGlobalLogging logCfg $ smpServerCLI cfgPath logPath + withGlobalLogging logCfg $ smpServerCLI_ Static.generateSite Static.serveStaticFiles cfgPath logPath diff --git a/apps/smp-server/static/contact/index.html b/apps/smp-server/static/contact/index.html new file mode 120000 index 000000000..1140bcf31 --- /dev/null +++ b/apps/smp-server/static/contact/index.html @@ -0,0 +1 @@ +../link.html \ No newline at end of file diff --git a/apps/smp-server/static/index.html b/apps/smp-server/static/index.html new file mode 100644 index 000000000..e3889de2a --- /dev/null +++ b/apps/smp-server/static/index.html @@ -0,0 +1,514 @@ + + + + + + + SimpleX Chat - Server Information + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ +
+
+
+

Server + information

+ +
+
+

+ Public information +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Source code:${sourceCode}
Web site:${website}
Usage conditions:${usageConditions}
Amendments:${usageAmendments}
Operator:${operatorEntity} (${operatorCountry})
Administrator contacts: + +
Complaints and feedback: + +
Hosting provider:${hostingEntity} (${hostingCountry})
Server country:${serverCountry}
+
+ +
+

+ Configuration

+ + + + + + + + + + + + + + + + + + + + + +
Persistence:${persistence}
Message expiration:${messageExpiration}
Stats enabled:${statsEnabled}
New queues allowed:${newQueuesAllowed}
Basic auth enabled:${basicAuthEnabled}
+
+
+
+
+ +
+
+

+ Join SimpleX +

+

We invite you to join the conversation

+ +
+

Get SimpleX desktop app

+
+
+
+ +
+ +
+ + + + + \ No newline at end of file diff --git a/apps/smp-server/static/invitation/index.html b/apps/smp-server/static/invitation/index.html new file mode 120000 index 000000000..1140bcf31 --- /dev/null +++ b/apps/smp-server/static/invitation/index.html @@ -0,0 +1 @@ +../link.html \ No newline at end of file diff --git a/apps/smp-server/static/link.html b/apps/smp-server/static/link.html new file mode 100644 index 000000000..cbab7324f --- /dev/null +++ b/apps/smp-server/static/link.html @@ -0,0 +1,527 @@ + + + + + + + SimpleX Chat - Invitation + + + + + + + + +
+
+
+ +
+
+ +
+ + + +
+
+
+

You received a + 1-time link to connect on SimpleX Chat

+

To make a + connection:

+
+

Install SimpleX app

+
+
+

1

+
+
+
+

Connect in app

Open + Simplex app +
+

2

+
+
+
+

Tap the‘connect’button in the app

+
+

3

+
+
+
+
+
+ + + +
+
+

+ Join SimpleX +

+

We invite you to join the conversation

+ +
+

Get SimpleX desktop app

+
+
+
+
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/apps/smp-server/static/media/GilroyBold.woff2 b/apps/smp-server/static/media/GilroyBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..687474f86e9c7195aa6911da93cee031222afad6 GIT binary patch literal 30884 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 AA^-pY literal 0 HcmV?d00001 diff --git a/apps/smp-server/static/media/GilroyLight.woff2 b/apps/smp-server/static/media/GilroyLight.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f4d4fc3e0c5d9de22e03386d2c2845f1019c8cea GIT binary patch literal 29968 zcmV)dK&QWVPew9NR8&s@0Cf-m3;+NC0MTdw0Cc?o0000000000000000000000000 z0000DfwyxSE*pWVC>)%A24Db-2mv+%Bm{O=#3!{@voz@d4tFO!38mMyX*%&k9@ z8^zkrYbX9HDFiNmkn}@1o2biAG-qF%ne5DUPZzz(QZc9)RT|VQlmImcuZd9+N76z| z?^$Y*y;bXI&H}OWRO%0s~E7P5J9C$unHAT#pxO=VpXWK zOEo}(mkJ)LPS^at{5zce38(vR&0Uf^2pXj2k_j?J)Mm;Mks>0)bcp!q*kG9I%Xr=Y z{{Q3Dx=)hjMn84QNij~#E&*(c6rcjI7ichN#>-tVkPGC(k_JcxQej8~8Tbq7bR~Cx zXC4GVxBh3g+I(P-KzrST_+i0^0k>T!0$2xHl5}4cy8}xB2 zRiuruF&LY1xa7yG4OCR73|aL@`7V6_^UB`cety0@LI`OH#tgxjVNj<+JqRJh)Vz9Y ztJqjDf1NiDMq~6@*&2cnAfj9GmTfw<@0nJnW%CwYJpX_d^TPvvHo~8c>4(4k*55;Z zP5f0wLxT!>goZkKtq84f$Qw%%AUJ?K_(2FnK?-Dq?2s3VLpi7lwV^q5gs#vZhQU~v z3G-noY=rHw7f!+%xCsy78N7iX@DB~W(H}!G29q&6=EI^`3M*i3Y=W(@EB3~LI2tG7 z44jWkaW!tm-FOg><3+rI_wgxy#NYUz9LaIU7e`}C-u(aU;EALR4;*Wg~5d zEw^oU)UMcL`{aNQo(=&Hf4CVafbBsMR6rf{gV|s)h`_d0l#RWNPGi8BWh^w#FfOUK1Q{dM z1p9)sDvvlLqjl_OlGJ@HII9O-V&5lBHhe5NYxsg=+4L8K&47!CnhEx)NXuR7PSoY| zB*9UBm(=myEi8Jd*OSKSN%l$S`*HrzpX?XGAPjaL++UMLLn9fBPMjq%&g76%o)H&j zbe+L7=?P}~B%m>pGkJMd3yWEA6EjB5qGyd?!75f~D|TjkvvsbREhvlTD*JlO^{Mc) zB#g<#+<+26Qku?<1Rf*vg*@NG3u5~V?7-M!9#-mL_M_P^itjrdxGMu+a4gBfCt{DJ zbqGi5lF_>Pk<=PST3FDInEUap8GST~LzAPiIbJ=)$yfvoSXcH6ghjlZ9L^#xh^~TD zs?&|oHMM0|>Daed%bd)~+AWbKL99IT7o>zibhEowG(dX9T)TGO4I89-) z=jh=$j??Z4siltNxK7FOM9z+*U3CI|P8Fg6+6|Y5lg7wwy@um>PD#LXlWIo~p$O*)e6DNioft(m|B0~@e$O*&{2m~<#asdPZ+3IG7i5a7oS!NNJ1WO_o!!b$IMg?xS1)r#&14-I)G9{9-kTObIq)d`X z;J7V#kohvrk_1b7*&7R}kK_HRHM8cnv5*wU0S!Ye1Ru}Z#V8dkC0ACgk?25qB;ez$ z0+qp-gi+uf(c*GM#R38r^C`k2LQ+W+EJ+YcTyZ@6j9jwK()Dh{=Sl0nVt^;DK(97- zET=Bhy?#~FIFJ(uhgXESnt1lLrDR*>`;7{Jvd-I+>vB^R7H#J*+P+|!MX%7BEX=?G zPpBx&qHL6pbrh;`T#z}Vv>-@I#25)Wo~@ih_OXYin|EQg+@`s9ZZdap?z-Hqxw~?| z<^Ip>`8D$E<+slF<)`vj=ikWxkpEN3DVky{yD5XpYy~T4DK{(kC=V%5C@(9YsB5Yl zsngV5)LwN+U8pWoiMqdfhl6&Mi zc~8C)pf#umjidwUP?}3e(P?xpJwi{=a(b2iP5;k0YsK2L7}k~bV0Jc<&0s6oI<}P^ zWT%*eRj}*qA$!d}uqsOp3$v(}I+m7}j+PKhtR>OX!;)p0X<2AlX4z@kXDPE(SngOp zTBpvcifZv@P^!vx8(l36SoD+Umu;haYFC2lhor{LyqLE zF}H}D1GKtaJ5acN-wCffNyj6?lLmzL-=2AJoVD-RoIz<`VV8P8e}3`I?b4j1{We*{ zpp{m#Kfho9;kg;vHiM9H=?~)=cGhbkcCye zD6sa*6&GkHi7y;Ue2(;&&A4bRN#p$=qru%+pjLys3JbITd zra}4Crc&Vcy|UXC?n+9LLVFx{u~Wym8h4UI~T}ODu&5!vJfx0RwhH(Hs$l455P71=yT3J2T_klNF8MGy3J z+H*u+O3H|x=d9^8u&mb;t4QZ4M5R$^%xxf@&DuzzJsBxm7D%BbQp`ga=7WnQQdnWO zBzU3>PB7IOA1Z}*nC<`@Nu{cBy{#15EpQ_jXb`%Q_ij(Xja;DZ(2a_Bd}Z(#ekH=Wu7eaiHgDdsW6b7!fj>Y^gZOb3+8cB;#|`*6D*ZY>9w1>4%q+o~ zuqU6x1`0huLM!M2Jy1d`?4i4?wQ#T~Vd%apA`HLvnjnQ{s74iWn1Obq7Bf||ir9^A zN4RGCx{{u7!*8z?D^_@cYzjXJErN?m3U;S}RF@!?6#DFyLi@U%J76D!pPrVzvX-#O zn7n{tx|pg#z$a(`nUL8G8ejkhG{Xj%iJ70U0R-r0YoS{gppXiwkAQ+3xIIFOshHXY zDZ1$@Lwa)rxNo+eVGX8dEPCT5v{GyWJ}h-2!SAs~8q^RNgx+YyU;!D}2K=$NU{E{g z@g*+IYGC%3susv+ae+S)k;gAnq4k1cY%V{>)GiUShzSihxRM4)qbi?+{{8`2)=6EA zG3pocBW)W>*vP>9VWr6RPkXh1i@DMWw7ZfvNrB(?Ufb`t0|b8+=z`jt`7XIz>iMpD z+BRvF+nH)J#<*cRtcFCGO%f@##y;2z`j7+)NjL{Ha22^ip$lX~7tAJVuo2;qxNR4$ zDn9ndAwE2F$#8FUkGRQiOJQ6e&cztGWUOc}Rn3)jU7Fwh;jFIhmb;`bv~=2Hq!du# z9JrZBQCw`c#>GZ!61|u^YfxBYhLDvM))*ngRililD4Gk{3NuMK#br2#2>SA7cw7I7 z@OHY;FvYZEreP=DDa$c9M{bAlZ*T-I{qC$_!(-1@8D56<^(u8YebtWYT+1j;m{ z{vb9FofMY?dnd8 zR9-tVYTUTdqYB4u6MeN=)##q#8ht7y3b)ZT+*;Hq-^JqM&CZT z^5H|^mDakTrX3b*@FzSrt4t4euw?f=_{mEh&mkHH&gRbD^SCe;9Fw@oTtMtbZEREq zjovX8=nX0G6h*Cnf7^0v2KzMg-bo1cf?0wjQvr)!a32i*w}ksZFcJS4_Z^zkYKEEh z82&swY+xU2H$f_>h~TRB=9jBapT=JE_m7S4)TuJ&l`gb+`Y#6il<}auXu6B&7Ez*??r8HWZdodto`S)T*tlHT&IG^2KqLh zSTuHz9gCfD*?gP(H*UQ4SHI|TcYOS^^Ned-`n&9{x75fF`}G>@+d9W`h4SsyyhpA3 zXV<^Fb}QB6ukNk7=h=VncWkchfY+VwZmU+#pT;gN=v;K7+abPhn{T&ITRSoTxxvn( z@80u@ZR20fi}%=hJ^k9pa;_cKf8aPTRaNYM}(3iFU*LL+2^rZLRdGDg!xWP+xduo#&FOOUD10+|L&k?C+F zEJJ3%awRi`RYE(sNo2OL2AK=Dz))nKa4W1u7Qi}@g|GoxB-|#lL~#eQ9PWhnuu;hh z*n+GS?h;uI_aJK&9pGM(^_s14KWx*oS?CD6pc6a*1K>fCt%`?{9k3hODRhQMklmU` zVUL#mn#V*AYn~7eAjjb#asr-0PQoGNl*2nB7d|87sWV1kcS!!`H)2o5qhD9!E?|RHQeCBm#FbR<-ov4@G6Xe z!O$K0!!yti-WN4XxDGWZyYE*TF@GGt8${@xv zgQaW|BaP%n`CPt~ujO~?Cevk+tdcddLw3mlIW4ggrB$d(tE%cc^@w^_y`#QTzpFph zEVWRrQb*J=bwT~BjjpPz>$>`SeUpAbzo6gH?Q{p-Ll4&D^&GuhpU|gtobVDw68VXm ziKdC3iQ$Pjan-5j{N}WFx;ulNSrKA!wE`9rdcCtgMGUhjVI3GZd^9q&EwbMFW5FK@cH!dvBS@s4>HeDSsK z_@Q6UZ{a`fzv_SOxAw>SXjOunvziad})3SH`XJR=fuv$7k_P{DMoz*<8pK;0kd?xEfq7t}%ClJH>tB zlk$)^_&j`3z8gQ9-^c&r{|I@7fx=*6oG?L{B+L>n3Ae=(;xKW9xIkPaE)kcBhr}b| zG4a0mT1qYnk}qYE%1h&<1=3P!hx8$4#d5|v#Wuu_k&v_^9Y_x{fD9*N$Ye5u%qL68 zDzcuOmJ`eQNey9IgVwQ>l zCNRn@7O`wB4=ch-v5KrZtIOK3K5Q79&vvtO>@oYMXi7#Ur&39&r!-L7E5ns#$`|Fg znpy3t4p+yhGu2h zQ6sN0!?9 z-TLlWcY%AUrI~vEBynqW8%Men!87-_@V)pAQlTQLr;~!jj>wh(=ALDbd1c zO|&n%5WSD<7B}6)UHe>zUB_LgT^C%}TxG7CuDh-Z*GtzgE^?Xf_uL=4KX!+?ZSF7K z)!jAS_1ul!E!`e>dv~h4hr3T>S@fE zS?t?+y!e=-8J2N^vQX_77}LGqF$@;0U7WMVaY&mzWlHw{h2I6I8A9xXqw=inoXTsA zCdz?Tn|2?zuZG^61|OW_;5@5ISLCVksVK+G;HYEn@xf6lHBOB|nrEGI0UeSHf`WF? zxgejTO>{eGeyN$QE@O#GA5jN|Hgg9V7F4Ydse_=ovbthc*Yq?9AT^hYA7@zwq5)8I z`JY6TXmuC$U>oYx+(@TJB5jLO7q|;mV>hKwb520gbN(zn^IM2c&84dsTAMy<0Q5Dz zHGG-a7wK#IJ`u^mflTa+eySFX1;4B`6*$`J9}9l^E)i#1&tt()`;(x()jt;cfuHvG zLXNZs#e$!n8Mzt2kyig$@JqLGD-U^-H9i&sa0 zb)#N@{3u&S)B>TEnSd@~Va zH~pC5sow-t{a+zE!J}Uu);)yW*vb{B4PRGPp8#x-(hEm_D={G^AD3}}S4`fGueox- z_qwXwLfnO;`TI7Xa2md@mRB2_p4qLpz4z7;1)wj4sq&(2G)|@NWQv}6Q|0|UD+M=$ z?xxJCu*B*w^5Hq^n}MSYhX|YIT_^5GP0MfHP%vNY;I?3ecdcZP!i%4yJ_p~*BZjXn>TjvokCf(Vi~3u-l8EB(9N;3Xf^*-+7sAp-?+mhz z`BIsoF(eO*cK9_4d2xd3I`hy9^)@yp0{z;gE$TgxCdm){`&zcfe8F{-;E_+XgLde& z8rBAyL^q?bzO^l6LRvoVd{+%8rhP_kXY?fahYH%Y8|e$Hq6X3&qrN0@p=?lo*Cgeb zx8;^5;g6ukAy-*DXL91+38ybB>6h+~st6weZ~Oe1Sn4#-@oF&=Zd@DJzt~|$nHfsz z`N8*}*{OuD2v3K?eSW8mlo`so)ZO1SvX4NAPLpVpu`vbrZ#lANth`Dsu!KV2ebj2t#+a5#JT)5$=el7l?E zcg&fw)v0H}V!AJ{3QnS`T^9pYr6iR;|M6$~jKVIWS?MEAqGyz({#x#-T_>SU9j8vy zjTJ9|(l~}&$57hC+nWzxbUbdlr>N;8rL6SOk8kX3@fE=>87BiFIjAMI zBxDE2HyqR=ImOBK$u%g0TpXy025sejc*9K^Sg$0Y2uUFVJ{6su9`uBxJ*2-wir!BT~Dy`m*DJoC0LOII_-H*DA zrfIe|D5GT?Kk_^)S1g^!h$N89J)#Rxugf?j&J)N=QOJ+1by4Hj=Og!hC+O9f0`uh! z!{@{Kvhq}*?H(V_r$b2~-*4=xM-)=mIDUEY$?SDn_Pz-YK(Bm%Dsa5je*k)EdLoXp zp3@U<=!M+W8iWOoFF~x;Bg~% z6|dQ3wm%YTXer5K{cDle>=o6agf1;a8p!<;d;%{>8iO>LKl}>iXk*=Th-w{(8{njn zKqRmdBvt!n|J}6L;lEwOm~d8`+$Lq3Q+89AFMQ|GYP0~Z?=Xj&BgEGzhsZebx+3wl za$CeN>JJ{!$Km=ar1F5}3sOi690cQN#WFDOf7ArVQ9&xwee<+v1lvF#CpS@u=(9;_ z2wd$ozfaC-&I$ND=gx_}H$(JsbgBd7n2$sgD3NP60GDeqz#&1ycMPmLwP3NFBUQnz zY3j2x$}x>UOia6?_|(#db$m*?Mxo!oWmHy=9J;)JIDgI`*W>D3lS1oJ6{8oq!= z;kM}D4qT4-M-(r;r_|H2c4`I%Lio|SD1%H zb?Aair6U>V`>NqmTAzwkgT39}mtQrT=B@Fy)Re`q@^ zm$$<0TbC@~fpaGmI+9>!Q2bZXBMm zl7!6O2%2t&eJ39a@*ZKYT4seVf?1evZ5Ly+Hf-9Lw|DCar_2tRGqjAPX#)n@dv6_B z0QwYM3X#-~R*6Yirt&17m4as2JzLNiaURUDyMF9p<%*y&;ym!fd$yo4BA`+%$B9Ae z3W`&Ls&0nevjvS20d>o}o`t}_(I^=Z@qUFK1^9njykF_A-6>Z1W|02Y?i4F}ADDre z(w>YtzQ(9$(wbDHy6kO?dS)q}tTFbqW~P+rY`<)TUSNKtH6OHtW)B~Vt>i^DMtuui zoK^$2(CTF{*StS#@thYQ`vgbU2Z~IFEi@zo8ktXolm3*~XX9?m&#krtow2R-ucmF* z0@*BE4cH=QN{={xYUsK?Y0B=NsT;iE9LXPdh1Of!A4_+pb=xuV&~at((Q}!X!w15x zkym>jcbXC0K~U&VKU3(*qgkVNJ9rb#!zBx8GSfxWp>@gbj}(5u(U zvZY`j0!#7voGli7iq5UZtl$Mjf>dM0gIh}4+-MdG*87er1rJF`!Az6Uhe%vMTN z8YHt7oxDc8%#KC!%FJY>qn=nKuM}RCVV+ndFP(@08W0zYpk_b6}5~wZ0qx z)o5M^;7`r`0We=aG-AXFjjOCkY{Q?~I|9`@$+h-sza2aNcBVs5L$ljnNJDd}R){+J z*+7&N+TIeiB%=@YI`LvyLteBctec;RomJ`K%o z#hTuVN{t?ZD<_mg7EtcKqO*jfL~G3msOiV#BB9^cl3Ices93p0(A&h4hoG zFF1{+)7zjKW%(W=sXzThDk){?f>@xOrr!(W2c@5Zke$G%r8n&)!h>Q zt<)N+Q-UzRC`DCw%NV0X@F!eRiWH~m2a!9TA-gPO`x)w3gnoL6`*GP1XsL1D#y82N z68cH$sB$qsROn$BeyDLVJybfXRKh>;O*WM)DwWVHg=_OXdG{ zrjJo#x7|)4M>s{kd_o^p^tje^t5bg~kJxs?TN<_->9KW7?1X2e-_goS|5j>^RA8+T zqmk5gHWJhB+@{HP`iEXvjx&bO>|{o1+}oGp%5~^-umF4D3;B2hnAwZLa7eTqXH-24 z0yq)$iE@tNn~xJ^L*r&1R>aIl8fC0}0Tj;>KXM?)@t{i!jRUhd!o~4+j;9D01!g%~ zoOz&8AcrD;q`lFv z8R|lIK+G>>^hTLHIAK8_n@?QjJriFg(^m?)-wcOYRk2~ud4rB$;77P9xXs09&Wb`- zXVAGCUKj7&zL6C%=fT_jp(rY3b%w>aFw7*q96EA0vTt!s^w($z3X|*Nv80-#AsQ6$ z_U#7rZ*Qt_MUoem%4qcTDH@_d;8SX3(hwBVS7}I+k6ABEIR@VM!jfu^hG=k-(-0IU z%i`C%c{wuAD$o|!)L?;*zj3_Iz;wmI zs4EVmt9<8DTv+IUxCm1fe$E`hHR^)OB&xJNLuK-}3@^lI^a9gm2cs@Hj4txsOL1YL z1LDHT;&kRJBAx9Jr-fv-izjy*^`-h~gwVyNFzuEpBXli17q_nst}he9j+jSwO79X# zK2J?AbMmLQ|9MP=7nF(|I+0II-K0 zM!Eu*yo!pyb(q)renv6n&_xO9#}IRzZNbh$MjKA^3K{dA_3)p%diyzTDB>y#orfMW zjHgyGD0{8(D7lkDqw}aWnKWLf@n}sZM&}uw${#7nUW-;RFkY`4=7mA{3@V}uj5-g6 zq6+9Us2HTDz(bk}u z)+(%!KiE&#o|t+tVY3;IB<;-alaq%|oE&XZZ}gTi%xbOCFbu*l`F!!jVTt1=4l-4W z61tt!`SVOxDGHSd9DVw0y7A?JL9;;-@2U(VaE9LY`|nms?P(g0_y$6m#sHJ{EI) zZ>9Xn%EbvgkCQ2iN+=2?QoOawE_%p_C%cp{BMzmK)y<)(^F*|r(`-72+1r^NvvH1Q zJM#HXoLtQ<2# zyuh%!Db&0m@&RNWn`we3x+!R$cRPe+o#e<6YMy7Pi+H98uyZOfgXBv-|u$+2T=w;nVo)sfGC6uD*E9Zr9VlD}W6(xE>&T9dNgW++s+D^<^DJIF9i{2zNY^2H>2 zUd*w=B8xFpxpT$whS2@XaCS4~YUz^+#?sT-S<` zVpV^`7V=r_0K@FmWR@H+-|;DG#9Nk$G)BA<@)2nSIC|8ZXKz;K}!_sa3>fo;CJU5 zntx2K>a^YP#|G`@q2$<%si|>e%$#YfmrpfG$(0@``21AOJ7ayFtdZN`tEg|jRiJE> z(5~oy6xGvPMa7c4p;BW}4_;c*+~hGmZ3E(q&Y0kEbVrnaec-7MAw#b5qraTCRUDff zXOcHLu2YO2S(WhV?YX08cNpprUcy9=RI5iZ!)l3EJFr?o4y~5drR?AQd)7xR_oDG) zl@u$}1Z$OaOh&vIh6HPvq%Ps3Sb^BFgQv3EvwN-<8>^JGUnU+}VUX=Q!_Urt*?BGt zv*&Z1aGojCX3YEPXHx{G<+86cewlo5g(unUh*nfTtifH4e98>;xX=OG_e*wwi5_Wn@srtpJS${FPqEWnyp*jib`81W7ILjyl8XPlL%Mj=_)SJ%Tq5@m z2?m9yVY^)>{kIONk5>9LHkDDU_EevmPc(x6*@wcF4;7c-jxTieyr{Et>t_1>uNcmK z#cs>$VO*Td>3d`rGK!8_pM`LTROfYpCm!$;F%HW`##GFdu3WAB-+dikt*`h5bsG^j z$~ZJ#y8LU!AGNl-my+5KNEsX(V^RTalGNciAObXW$8yPTwMoNsh5Dc2A1YX@kf`Fz zIp&BQDeSlVneS|9c#olBr>o?#)>IdZLrBv)VR|x6rzs9=6!gZ9NO25m&B_UK~ig9jHgI)x2P4G9_OxMv{9nyr!RfMZXeTsj6#v^~}R;bs9TgZ*tJ)lLYVwFQt7udZ-uET08i_xun#0=~@z@$Q2AgK#*Mg(ez!7|Bi#iWV)3MHN4 zP%9XzZF-kh<(k8Dq@Z7)4})w&%XbxKA|mve0Vx-b0U|N1H#-^@7EbvLp1pW}mb_KzRWrG-RBCADY~b?i22>v2&U z4?6Ma;e``ujK&udgF6jMq{d{^1)^kw4ivC{!0uhv&vv|ZX8$YkNZMVzsqBQ6{P}&Q z22IiAN-w__{d!RoYTB>nqoLmAi~4YS3~$kL38NLid39&B2&q`qmEGrP)SJ<2gkyDg zeve~)7e>1_iI%hYU50Tj_N(lxy`)v1h61-YNDk5hpsG@tq>S zQ(Rp~41>4ph>=U2(ax<-XVOyVn6|f_&ft`D=BJaKgSsn}? z*%<6Ptf*N19mh25YHxf&Wv};3;Mj8>!#GQa&+>YRnLPndjFi~E`aCL7RMro)O&^oLxzdmMLdSu4!?A-Eh z9lnu9D$l=3?pBgpdZ#R6@3!RY&sR%oqKIpwmB?1h|2LDeWz|ytM!9x?(+T)uqgGnH zx2$w*aYp)>u^H)m$CR0>rTi@dCvYp$;{ZR#fBZXd0!O0P0ER@b1Ke_Xgh5(~T!1PG znQ@e<6j}KNOtoC#Ci1nNY$P}5P@X7=CDNdt}qI`!l=Hy zEK9=Y@+{IN`dnU?C81swvsW_&Rxx`u!{_pv!@@bXLYMlIu2JB;h(NjcpSV2j08hen z4ARPF1R(hc+UsBer^%yZ_=u1x#-SO~<*jG_xMq8NYIuZ6%_(UV_0xA!Vw(IPqL`C^ z-dwn2$K1jkGw8D+S*8F=&ZPD_v1dcz@#OBo2fK~oJEZ=zV#as31;rk^W1=~d_M@M! z(!sMFJ`|^w$oYtql4aHEd6ttk|BZ$sZ~`x;=POv^?KYaQ_hMTu|7j+vJxZv#CVG}y zJ!O>^;TE}QrAoJ@<96QE5vNp7`!i*|adiEzKdYCovDUEmEG2V9wgj{ha8dkPj2-mDn3|PuUW6PZ_HzFc~;M>CAXbf@-e}j=!fw4x1A0E?*_#p)I^)))&22*>gbhzy- zf4)U`0q3{qM1QX@y$SS4=&Wln<|}sUb4KpZd0S&=>GaZwc<5ZR+_!! zK=t;zCao$A@AsbX@9&EX508%v4>z6f*Ncn!yI_%ESlF?V-|C^7b-Yxwcz0$^g=X0$ z>xrn3{2w|ZSWx*iQ-~Ia-7o!pp&sj5=zFOr)_Mz^nuAkr=EIOz{3GigGN7Mazwnn< z6(0!RmZqB@=y4~D+MjXYnii7PEfO4JH&?VRnWc@JlO6GbUi_L5JtpDMm|HM%y#l*x zu=1=9DqXmfLh!CDH(;=QscpV_pW><@w-?W@A1k9_Vj#|1t4uGtdPayYyh$-;*Y`LW zM#(>4sJ~OC6jcz@%8dE3vWR2gZGKT&goOkjw*jMOb<}gSbC$&K517OHS6n^09sGLv zXbn8$!T-=IDERC`^I`9bfO;0bKe~7m`uU6H@MDt>%e_J~D6rjy+yA8SuOFnouESI+ zz7P5B!p~ieH!swcTKDKrR*)M@lRaT_$T%}%(N!PYJ|X>y-GS z?lXe5u#aNQj=MM*0_E>7HvM!^5zBa$FG#9qqxPGMR^d<^7HV+14JOdB>Zr|ew#75| z2h3r=t?;_`!qA`jXbsHpU?}xK!PnM{PoaE1cEZ_#5DRG-ywNcoB zAAKx{j&>d+@ldxKO7Sf}9_m&@sp#O#d5q-7r;M1Nj0FEg=mRKDc*uy~j{9)Fhjc#k@sitoTTe4=zvs9mS|s{KR* zP@_K@(II`+exd=W@u1)@zQdh7Osdq?uG4(gexea*EChvK>Z!QnFDA9~rz9Kvh)*^|GjNpd{YA2AVia+u87pVW=)kEPg%H=oz651b5NV-Z*GSQsi_YsZIAH1y-CsrD z$9E~}8T?v;cnE?Zi0kcGx1dq4j#jsj)V9(a{aWZnuVhNvbtF&WWB3+wCZl?0xEA9v z5Q8vIFT!emkH0HT&P>iOR$59j+b7A9)`alz?t};TPqnw4P_j>+O1>vQ=SMT5N-nDH>~z((_jt0hk15&nY*`kWKQ`QIYi*gUv>f-ecFXOqg2cq4f@DR+F<9i+ zzvBGK?cfJPeLR|@Ans5@1aAg_dVi61|HS_ zU!#^%vG^1^B%bS0|3~3{9){&yCVY@boiT^POR5&%SHpr1c%X<5vWHV_+i& zLODTVyG_O2{78lFs~VF(V#NpCYr5pi?+;kYFIL%Ai<yCLew89)$g~M4qi{JCBp3-p;c3_gPJ9vqF3anJ z-p;?zc5W_dRnS}>9f-qZ5i~ZT)-SENrmgK{&6%?&3o|l`3sV)5KW`I29e%%agMezS zv`heX_%cC+5Z`!ikgy4wunC)pnZZ8TN7O$W`0ot%;$9-Qzyy9AOkqFnC+eSe{wKxx$E}w_6EqRAg;(%4N!2hlmK#e;xlxTJ zs)oAlgh%lxDnbYk_(9^o=c|7(+z0zKC^cbI!cRyq6I=KNeok6gt;VXcgpXi0J5i%} z6!W-=8bC#O)%PwJB=t`U`(ZyZQ>@sSr|2v4KWX}NgrSmZSYqe zH}H*uPjZ5j49sOX$(xzzCWklS4bscdiK~dOn281O>hMMisqhD;!XG4$Vk-VYU=g|{ zj086f{veBPBElLT#<_&^F&w9&1505#PJk7Ctsj3?r@U54HrlgZw8~h5;~^OGARETR zNJxW32tplN@hN+DhHdi+j{G!|Gac?C&|*yr=#E{*@K()Cs2M=b%4z2(_k9UDpm za3yRa#jYjCPHC^4YrfUjHaB6X-c?C%Esya`l0zn%k)`jMd#F?ipJ$Jmti}I6d=~WI z^;R|4>2FLWSmTz0tu2o_s=DtVR$v`IPv#V*t&W$)GQMYrfG0h|lqrFMcPGOjMg7?0 z`yNINd|&VT{tOeJ-eyz@x1%fo{b%CqI)!|6S3pY5TnB&Qe)eV zq#sW3x_;@&kr6xlZ!&QobkcXP-noChvCFl8)V|TmjeZyUv~cts*{jT08!5Axayq?KUGIPU1F&9edx6bc9z1{4vk+d6@G$_tlIr7^5``OoC zW~2{FS!W*FKu-4w+uO*A_UcuAe4bVXyQ!uBU2IO0ev)M}Gq>$R0X|lcezrq)Sp)4J znhxegx|}{hlVs9r5I{0kZrGpUz@(-+n3v`3t^GQ6955i<)O?{`8pGf|XR*#ssB@M= z>~_7mPxHT|7bfone|p)U%%hio5%k{)$+Cut5MwPrB92MNnTDR1n5?`1g=91IynM~v z7EPf4g-C9TW;3)~WvvbRCFU8NVGV`=s-fK~YYo=ZFELz#0Z9;m4Kkns1f*jCERi*s zV^zsX)NvpNViionBnZSRFa-*IKYHt`xp>jkMPr@a=%lIBr%W~5#?5JYnk835KUEoiH_Zu?f-g!a1p_ z^NsARJ3F2`pWo>nnPsLWZ6|OW)oQ@j zG?qZIF=mpp6#QTs_~A5imTEY@6w_L?Vw%gkIz?z$c|OxH-MShrG}kUxcPGQjDNMtB zmb(+v@Tm2u8PjmnDyBvKi`Mth30RlJQ!NE2uV-jqDjG~0i3;XBZ**3iZ@ z>rs`&*KGU}Yx)w1Sn_%a#qLXFV{Ntn^Qg1(aO<(Ywa~w8{0|mpD`7Kj6>arxO>9xN zBwHWbP}@Y?BHJNby6vp(g6+2LiS4bI>{ZY!*sG#fN3UL9%e-!SJ@9(&^}!3h3wrx| z*Y|GY9pjzo-Pyap_fYRA-tWDCdV}3=SL}uCPJ3y4S$j2mJ$snFo4vPvl6{VSk^P|k zsN^lFk|q_CN=Tige$ohOth7X0Ep3$cNr$CO>4x-5`YipDJVYW25u_wZARS0AGLnoV z(?}}WMvjt`e2%eCc5Ia%&1_mhXp)8s|+4*7_jC0~+n%6H^X@^^(O zn&PKaR+=a+m9|O;rJK@A>8FfSW-H5;t;$a2xZ+l_mGjD7<)QLS`K;t99#v9RRZ|P8 zrdmo3Q7fv|)mmzOwXxb-jZkCMWVNf>Q|+$~QAem_)rsm<-Iqc0BEF2Ltz4P5cGODO zuu-FixmJ!^YjS^~)nyB3cNZ99BuP!g(}zHvnqFUE=?V-=o9U7tv+npXjzqrOv2J;G z|HR!xChsOd_l)%u16W{a>|7Ba&+cE^ac7^w^&s)SwJ1AZNZ0_KU_kx;te(&-2-0f9 zmE>>EzqigkF;_5Nsa;?=+MU?CA^RLr<(PSVGx-sL592iOttk}NkgfnwnuTFhO_v|;F<+aDDuQnKLGspTM6)QY?<0$T-*J zAp;$?FGf6ma`DW=`>oH^FmRX@)p>7L=7Bw@vk!ESjp^Ji($w^F;)F0Yv<+lojKh{= z*n!qyT5P{IU28azR=ERYoM|Ocem(5^I*=UgY2BW)qtcD0=b8>f;^43yZoBa|Y=kdv zk6&&$*JFMq`C}CdL9m6yQ4GQ@Bn5ibN`I6kYglLugF;|HYw+gn8D7fh$#^Wo0GdCfBF1dW^#qeGPND=BAD?p|}-zY~- z;=6v4&a_V`nLBdCoDoh8Mk$t|*ZB(XI-A|mP7cr=U&-VR8>Vk^Ze^aP;Lw4;{Q!BF zEt@%Gse#2jrAgym$+w?7w8spSp%PYs6y8_hgFXTt$lSV7UGQ$$e+%m(p#Ln!bN*Bk z{5*|gI1v7y%(UjFqHQM6Ro~5J^dswshgWmzjOZo7!O6x_gfv{ ze?4pKp3~PH-)lTAW#Eq7a5zQ(Jb(V@QNraWo6S%-px-(h)}TRnSR)gM!CDG)JfC%( z1G`}ky?3E?ojPq=*E6;A)^cmQudpt(65KsfeEE*40tKLnzn^y)# z?rG?371qCLH}k~?=7t?I6uy1>$^DG@5Cd5$aE8fThm%%SvJ*Xta9GAnPgT;(%6~%EpcJS}7#{*> z$LrOZe&OJuQ_ETO!H(xm4wnW9uGbpK!sxrfg1ccDNEAyf1+UUzI3rkChofiC96tKc z@p0kM0Gw!GeGVy5DNS&w4^A+k0moG`I_tLLP>68oMxh1}bYM-aiIZzs!_*?#>fC0L z;%2Ui!ld-jK1ty{3PzUfOYEkI`aNcK6%POXs#+-)r(X zftTEu8Ro1nBT%V3NnCmQwB?mbm0Gr_Uj1^5S0=|Qn9SQ-%X7jxBGDw3qcLd~v{EJ0UaJJAr6BA%Su91~vzyQj@fcj9MkGGB&!7<>00q~x*r5J(7 zFcOZDwiE{7dmMuva)Ck$T!s|9OtgP`Bf;mfGiUxdcmpfUnqF4uV??@DQr28n3u}65 zp~K)A0tZumgg)3c<8+e2M=}k&>uXodU9i$z#9|LJL&H$~`&vLb$R%8d`A2X*<+RMT zdXNv~`;R{TYIyN1!*r>9!rAL4Fz}UMW~DT29@Vz0sd?futT^5WQpf9%|NC`tm{w|x zCsi)fG_F+bux%Fy87Tuu-CYkme{yQN+nS!ua>Fxu&)N%(q%TR0@zPd@B7bsll}8KB zx*8_9iyf2tFg}9euK%=1u|f=5E%+(6dDs0SCJ?|g1h_V^TVmh(!M%D78Qhhh>U5}0 z+fE&$IkdvlwZY-wIpMNqJ)6OJ4qqvFn_ewC)^zfmaGjqMx#2ux?drJ;Rv1^=xalLE zT<=a7L*3UxOYltRI^ObBAXbG9?1&6EtqR0bA!C6k4?gangf-!VT&_Wb(p($)*Gezj z@gI{6{^gnb{sEI)J~{u~dX5qMg~6-L!LRI{Ya5@pTUTt8;F&q$$<$N9^Oaa%V{?0W z)`f8P?9wdqWP090E5N3LMP|2<_s@ba9&`&u{`mXEkw3;Rgr5;V&1?Qrfw&xwgjHFv zH8lPGsjMdxavJi1S0$7 za-Q1rBo>-&sy$!FLQ_SsE_?JNOT`p*RITLu~5mFNCxpE>u+D`%D$&0e*9R-sU%LvNVER1jtW5tyI77nnh)$9u{wR9_lGEl~rC ztzf|KshWsjNig7Uff^7T6~C|*su4q%JWxkFEy$*>gd=$?3WWJIl^?9kz8kE3`fez) z(2U%j(Xr#m8A6GeFu$|syL)!G*!$gm-`f`xraCTatgDu|k{28ej%3u4U_@A`crbc_ z8m;1sgSe=$B2N>Sv-5+O#hLJtJ)(5Wul^`zs0M{XaqoqnwClb66ICc~TMKVl;>&B} zyLPb)%xX0=B+TCjGecs1L1fde7mth3TvOy_+h2Uz-i%xcriLS-3#L%ZRNw8ExPB+l z1O3J*aiQRf$ZpM>ZVGiYI4b;mK}(^&AG|HH&*q8XQJw$+$OQoW)j=VIKTh}n_z0gv z!xyxYY))@$xfNP{ttE8Ht773j?a$E1O_(+Z(bYRPR-=9O%FSyr0Y(OwiWxcpfqYn? zF%yegBVTYVAs0;-7gYP+I+q*HbLMs2DQzu#qdmruK1+N%es+a0G# z4+-jf2_b~`?n2+!1>Y`n(H3#^KtBw@C``aK6k!3Dp%?_4-O)X!Yb=wQh3!qOKonZv zow+P{z@tH<5XD@89hU5~&ovqfwt-L~+^-r#a1vrBhPu zPDB*;DkUe_<=2#=vgfsjE-I*CpA|cktgMf49oGselH~|B%K8G5CcTIjeOUS(32>bz zJx(S~*^NZqePyhcHqrh<^@=Wk4pbAY|?pzPNVIQ)=mHMj96BL48yr=h!n=HzJoq z*`6gc0>kPFKZUS#Rb9EAblujCM^PqdQbWFM&{+3FWf4mayHQo)+R@wEQ;{7;96snb z)MF0vANAN~XXXsQD`_L2Z>nNX{#Ia54D8%c!`fNPx%;?6Bnua>`_Iaj+^s9gx06PiVt zV0~Igm%WJ2wmVI-e<37bYpjaLnR0?1PIHE9d9PHI*of_ZP9KlWbEZ?c(x9t7iFInk ztTE|z9_>h&jN(FClj;^SEv>_IpMk|NvDnl&<9~z|*RoDEnw@uRSxGulq3717jubr$ zkUAigdDYsqkc!#y5KJ3pRA6V1TvVmTJj^zy`-EL9yKJ+kGB2qc5+3{8oR*2s2)F3+ zGY%%8aw@M(vCX&mnVH|8iZ&-dbhT%vQOfwO(}tVNV8fiUmYU?Qb&jP3C&B@4Rb{=Z z5#vS=on|Gt9LhGwbuRZ$8K!s=u2)@*Q)QGT`3^!z za%o*l_#`}_OZDoMs1s>T4S_x=kF&KDh1D-S^RD0?J&85?wV4 zG!F<{idx6-MXHLPxjmO5l1*q=3IY)d?{SSYRx!HbkevRnf7K!iIcSiPHO#hv8($eCwqS1rol+ID4C}al8A*?uuHl zGfz24aqWI&ns3A*a-)`TmLh%;k|k^OTGZONl4C`W89h!SH|Z*+c(cVR1U6{*$XIGn zl_GNDgcI`XirEI;&wJUMMRCp(Ey-7A35{bhr5t60?s`H#0(Mx<&e}4MT;d3H0_wNW z$;A;mB<d%`7OQr(nHw>#+ZdylxW zSeU5muSAeyJ4EPAONMQiv^I|=R(ZbH05rm|GU2g&|641|!>WCSLhawG)m_aABM?KZf}~fd zJOqbdpRXaJX$f^eTYl(i5GJWx1G~|vBO!>@r?S^|Q^gei%!6E<%MB5lWR!6^S0P8c zLQF^pN#8TbtTOzy_EL>RbWN9l^{A)F5+Y_J)rj5*G0ytTi^xYpj7v)~y9tTNQzQ2m zy;Xj>MnrDV_cg>a0;5_mbjh4$m^1w0yWF{sUA?$xogX1HX0g*imJQ23(1d&pc0m0$ z!}v8{b799pYqFw#hNtXI}ep?cjt;RpeeUuDP-ed_c4v)ohnkPQbb%-Z#gT| zex@uXKFmIUaIb&R>N9iMOV?;wD~1A9krDzinR8t@jy8O>P4;VgP}hY7y`gKm3ArLm zf(}U*PVe}?zFa9hK_}58+P|l8LH4sAQOnO$dO0m&i0_oX$O;VI(P`*<}lU=jbh2OY-jyXjf zFb5#i3fp(KpSesWbXd~DNUfZu%wKde`Q~W-et4to@n+skS)T!!k?Lf7?1co3bewLE zleJ-F3Gm3N7C5;-HknQ4wzSIrn;OS*+Rfs)xuY1dWTz!(pN% z^UMI01~nw_HT2}C2Z=_0AAe z#7ULcd9t&ndJLn%{q*}f*Y1W&8+>180iLQKLqt(gixy+1qRS7uoJBaGXa^eA=+jLZ zgr?aYqlmy+=c(Hm^C+E2gev72xG*6+Gu(5)M2&~!JCLKw#8&bu9kVINfW5j+np;tr z?rc(2(WPyd&B)0Yv4>eZ&mx61Y*9o}9 z+ejp%jmw?yI5*ip!!@XdwsRRzYOYb@@4ieUEj(fg6bg9skQYUm8M|a467ioH5LJ!Y z&>DT*sVzEh?0jDL#25`$W$-jYj>-aj&zRJp;Hl(URA7+?E;{&h4#J!CVD1e|>d297cQ%wiC?^xe7C*tbzEu7Ti=`i-+i+QXvuC6jOlTuNic(DK=4 z87jm~4r;onlaEsq)t$?v2k|NABG7QN<_}K(A3BYW@!u(=1fNk}6oz&9A9MR}n=Hty$74N%MGNg*0?wTKhG2s z?s0e-`;_s?_X;xB^u+H|G1pNEt3z-CFR+&_@IXuUvOSIWsGMD@q|Dp1<>cw=;ZMd= zYX=MU>I+ma>J+WvX69?s9s})i(5D+ymRh1)$%obmK3K0|>S|b2&OVHnas{GP+0>X1 zP5iAp@1UdAu_Hpr(W%Ol3=u?HaD?E))MuQuD{7+rkSQJRbc`bhVs*yY9Aj`6!+*q6 z+zc70M~**8TNo-hb(blCG$kQ(KW*S>NED|;x=BowH0wq~W7Qpw!uq}V+#nlelEQmR zTMu=MoO0mLw#~lGcBb15xEJ3CP>NgS4BK4k0o}TKNSw{BbFm=rDu~bE0xt|CL2}QS zK2sZM(1(U{PUl>KhFnZR0}vj8kOkoz5E&q{K-BPuoo~1D+LrWEe6l~VKRr8%IQ|Qt z96X&i(x~1>5sGfqf?Ch#P)F)UeP|GkAcw|%?Q;svB8TSLOS-OVFK=JlzIk*f?Wd!Y z{vM4!LpAhMvX7U4PiZl1{`DKbJaOa+5pF}=Wo~O$zy`|pJu;8$ zwJ$cv%+?Cq2f)=CfO(u-31f|S0jUoZZLU1_h^TT4cF1A0HQhdk^@;j%tXpR&SP`n)ZSguW!V>i(MP z<@#FcwprhKFB=Jc|5o}@BhmeP`ZEt{js8>pxc>Wl)kx^+>{)t6{gVCyJ9ZMKnm$!SFgKIBr0`TBv1RDeR;Xgi;KSqdvP`2P!zyYHD zmf#tf19PATehJfIu!*H0w0PPL?n9e|px~T{<42;1z*9i!jki|H+n;TT0FVUaI#Ke> zaRF>BivmbX901{=_#fd<8_iEx58w+b#ClpzE0FrSBIT$CsDw%oqB5$2N!REa1T-|F zU#QU@Ku63*d(6dp6yXbefwhp3Scf`%g&x|*ufk5_CfMJkNe(n^nuE-l<6z6J;SlSr z=O~-njALBpGLG|0zvOto@f%J+DtyN0eBKv)$(Q}npZw4N1A`!l1*sq%WC_T4nU{fA zcqMwZHxTnCZz1jN-cHWDyo&dP9P3z)b%HZF&e;|@!v-5U%el_uTo<^2^IhT+ zF5q`_SGZy^T;)dee9tG17_>v`_?mC|j_>(_U+m7sj~SXWW7fQ7)>^QM&23@JueVgv z{>XrRP1EbWfrK}E3rTt4G(iUHp+mM~1&eH3+u7a@cC?FK9q4dJI@&RgbG);Roa1~K zy2!6|yxWbLip79wnKIijf`&B_6N!D6}-!{3)n;->gsDt*I4wL6W_@JM| zM9B|!<|_{8|B;{gmEZWCKlq3Lh0&9iTV=Ja8@w?(+1YM(w}(CL>i`Ei*s)GS;k~mu!a)r*o*n>%Ykg- zFpgt8CvY-*aXM$SALnu|2Xg_JaR^s%HAizTw{bjo^Dt-fD35XxkMj~2^9pZpJ#X<5 zw-7LhELmdY$RT+u38AqeKLTjv{+&b$>TW>v~&1^;*E0BhCCM39lr}7E?A+p1% zRYX)%P5e~R+!3|Z5~q$oYy#LxW92!7!ghVv_b5b-B}GKjzUn}~n-hme2ykBI;IpHPI9 z8jvw&jKH{YhMF*ehGvM&nk8Y*9FckR46@8JBFil&X@wPpR$58SDyxaCv4$bmT1(10 z>xitkp1~F@kg|zQ7-&$PeqT?LL7{@!Fq7$4zD+P? zgR`AYD~lE>7#T6i21~TH(MC$naSpSc>pY6icTo>voJaCVlst+@;Q$`PW1x8)kHZ>| z=kZwN2|NM2@7=x z_!0W~F+al)Kj-J@=a>8vO@7UwFJNo!P{tp%YpZ|v!5Wte4AeMy?5>=r@fK#6riET1`%MO!UhYR0s$fzjwk?= zkI+9`<%5F&8RSr5uVf~sfh;x5rFywgEZ6l3f{A19(IiV9<|P#fp6RmK=#8V-vQ3i~ zBA2;>NJW8xm^V}r7!La3=)uk-atH2!$ep+o3>A`3sRTAoj3fGi=uX#c^+GlApGKiVw43n@@YL4##2K2AMKIfcN=WpT$3Rc;gLO25X^Qde5~F_@NeGN2RvX(@zT>~NaXNa!NRS@hUbYk~t%CMe>-P6)!Shr3*=){bU3VX_-()C|&O(Goz{3npuKsCSx0o zZIO`@NGEA7GS_&ag1~mxS5%Q;cgc({nL-3o5Mu3%x`WfXgK^1ukyqtKQLTEPc+FIb zSBpX%*73x|O{ypey|SP(v%_FDQ6@945|k4^$7`c{oOmU~i=R(L*ot^bi)x87R>Vs; zQKDjk01|pXi~pj}gFs(DYHCo}kKmn%U^9}(Y7LQ0MRGKhjT6<|4MkG~8QBg=Mz#=H zYh-;y`S@Q!akO+jg=$wHb^(oS1M!#0S=o7&?|OKBrG%d+bG=hA4+MpCq!@xWYD zk(z{X$#{?_nfM5&z12)x{Zyv7Fr1WK~sRkK;8{1lHp7|G)?>n&5nBifh{W0*|5l#<3%eD{~c)D~ih3)$J0T~T^uercC&>~cfn%VnfrW*jYSgWYiuj>Q?c z7}w$EYfJmNqGc*qm42EDEMi;ifrD`z&cr3S9=B{2be9WBzuY92uz~HcCl0~!I188J z2HbkVsePR1E-(AZ=wE9b+-sSVU2FPzrm&3du@?@-2{;><;YQrH8Fkg^=bOd~y4V4G z<1n0vb8tCs!tIpg0@Tmf#40w!j@Sn@Hh|GgVlFGFq%!pRyfvstee!8X4+b)Z$;@LV znCB%M|9biifB8T%r2;QfWwYI?#*3lrohCtmYAl@5QR0FO5bN z(2?E@p^RxPWDSqftNRf}66rLikWM4TcOS=e7O|GcczoEf@l&13nB|SFR=sw9!d-#ReK~Y`1*$ zeP7z)Q1{IE(lNtaSN-GCo+3K5fEecW?a-HQIQS%{1RqtGgR3CXXz4Kk8{OdfmG|_N6U$InX^ev3%MD_ZgR5 z_lv*W9Npi;%lHWDMobwteyUd-k2=!jYN9|JopkROPbn=Q?KRMFW0jj~wuP2ktFrs} zlquOcUe9{jo8I@SuWhr(At$=$rc9eS#p|*k{N^9GNB{5P?cygyR5xL2`Lqe%$?D0} zK%N%b>7u8;862wX6~X_id#^t{x{-gKWYB+uLogwPQaKNEpHBSv^5N#<@Zvq$z{!S9 zHhi+tll7l$%wz*57m+wyKBXPqM`xdQEEAc@LRPYo$9bODd7sbO5`N05ob7+%AMVQ3 zx@u*zTCoFvxYXFesnsaZ z?`=q*z1DZ9ekApkwDkCz{m{l)cacNq0kLn_{0Xz$_QxMilKnQ9(sG;aamOpRjePCX z=KZUi9U@$@r~PiBZtAWc>Uq*?R-D$Hr+(^xzAS_JdpSR3yU0bhWqv8ymn0(lw)!VJ z+uo&~+n(F!Zv$#8N1Y*idn!dm{}&6F`RAEGOz&g%JfRw{5gMsc8m%!Jt5TI|oX#5N z=*hKT(`+T7nywj|sacw>Ihw0^ny&>qp_4kL(>kNGI)`lf{;>S#Slduf#SEvM*(|4$XL*xP z`I2wh#xC}8h+~}M9LD*!nVnYrf5NTtdRVKqP}6a1f*#QtEz%6!D%Yc0tHqj$TNQdt z>$F6(aBHF-*Lp40Y}}fpC$vG!GzYgP>q%|Ya?Qo9DSAqqv_kW6YpR}BrB-S_ZcWoO z+N@PtfHWLw1ky;PQAnea#vqO5;`o13DnlBFsThAAmo;jZCCOqM6;V%!39y?Gs?CajM5wGvzxF`UZdoG_+$-=|9 zZ<9q+=JN(`^8ugBTj7dVP1RMJa=xVh;tnd(fa{unGn%hTi?`i&(Q!FGPJk2U)N>wo z-f*_K0$g#fnyx%oYgaedK-Uo0ro?55|9T=kah^m^vZszG+mq*M<9XZjO;S`+a#Ci} z;G`##E!mwMoE(-MmzDv5Z1Q-4w|JM2`AU9@Qljc8Rhep} zrfPZb&*vSiGL@^+Vr-Y4ad5mGKPSwo(?8!%t{_*utClO@)yCDsHQ2Q=abe;=o^Vf$ zC&A;PgE!rGK+?wX&}QUF&xvx0-LJsq6{@}d|M$WE2cJE7fp)!r=?U)N&{(MQ-P)VvaL`M=9oFJHLa9_v;8QFXoQ;yYHIs5(%!rRv$Lg>a$1`^T&Ae?s70 zHBviuP*)Y}55xQ{!lEq363t_&*2D^|w~e;3^tWuet+n;`xIJ$#cl9iL);^X`wh!!v z12bGeIL$U#8#bJm8JP6UUll>TWP=E(MNhuhxE6q^|8LzH+og)^_I5W zQ}s%3>Jwd6m9FRm-_&^o5O_7u>wBn09Wu!xherQ@@NGNNiLUgbFT)tl7{)R|U+Oh| zWdZukYI-DIZ61qR&IUH}@U%+Ea#}w=jNp^^rPO@ z0Ugsp9XD^2C7CztoBU*s)i$@jw*>uZ=yiKY+w{Di(+j>Y9@2u$e)m=Wfyti`5{M?2 zIMS#~JyHm#87(QGkhZkYMcOcse)MMmrF38i)0xavW;2ah%wZvGS-~n+^E8k11W&Sr zcleO^c%M)CK$rN0L+s}O2RY9dT*R9hI84EZnFKMPP!w`tACwBjS$ z@fn@@nj*fX3*XRznkm1v zmTILIYOgLTQayE5EwxkIGin8Ox~_k5IxAC6RLGyI&1lXrf|HEmG~>CX@AR#9X}5NI z5wE4*&>j7&|8z_DbYFLMLpOC>zw3go>)MXz?3_x!{ML}*X8++hKhbd=XTe zwmQUl;B4`RVhk@HoO?@k2)G*jf3|vq^FYJouVG8o4F=gXf}>w{rxeF3D6v1uqEnux z#D1k-){cw~JIeC9JLe&?PGIj*V%L%4S`V!D9o0Wt|8&qwM-~rHvlMLDjFLN~-;e&E zG^Z{|CyMViC!4HN9!=TWIAonEL9f#g_NV|>TtLQ(6NtX^o}%c%9YmmWg``K@niFm9 z@$2qS+HGK2!BrdU7g?f|=^91St<**0-M8T{BAkAt6)AVt2-4hP71$+R1r*s?53sfN zk@0Aniz3Y=`rfhzw0JaCO`u~W_3ECy@c#)!w~I71WjdR)%VHu)WH%mU|OTrcf~`8Iw6hN>alV0d!gW3K zPr&X`;#?!lIc@*Le%I*V$d`kzH|`>jN~qQ}`3YTH**`i^*emwJ)aulhqxtcSL= z^52f`QKZV`QP!1co1OMCqV)|))>wL2+2ur*O`y%Irs=I4Pv_UmAx)o;JYR&4!AlK6u_m5{7{Bxod|YDI!RM&L;V(;gkZk~&_#D<%V&y{LzYvAX5E>4AthFWrnZ1W z8?u^BHMH@Ct_Bp^X|@*hIr-Vp@(!2p>{DoMaWr-6l4Tp{*gVp7U%yA1>JqtHOd6Ld zp)GZm!f{SZvYdN-!kgs6|9_&RdV@3ccK<(Xo$sxF2>ODHevj>xJ&xgHB2?eXAL2S8 z+109z4whkkT~KbnWB=FQWQgwA{lLXQUOX7>b@{@Z_*TDy%XgPXPb}iOQN0qoIArqZ zE-qr&v5T9TTu6G=+r@_j^-lWYNPiNgoeaQZLkQ^R)KL7ip4L?ST0v?ALMauL z<54Vir`Mr(eRiG7u~aaX5w8C9@O+sxp$Yj+U>M^V)jTs=#*e3z;pD<@_?~Y>0~*tq fhU1B6#9uIzspAfd_GqKO)f=du^(P#R0{{R3QY*d7 literal 0 HcmV?d00001 diff --git a/apps/smp-server/static/media/GilroyMedium.woff2 b/apps/smp-server/static/media/GilroyMedium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..895b6d4f1bdf009e723d7e12b85185417ee6abd0 GIT binary patch literal 30648 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¥bNSh|gNdoVz7LdwFQrihxxYgV{ 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 literal 0 HcmV?d00001 diff --git a/apps/smp-server/static/media/GilroyRegular.woff2 b/apps/smp-server/static/media/GilroyRegular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..24465fb7dc8593f0924f14682d1e1ddfef48588c GIT binary patch literal 29136 zcmV)oK%BpKPew9NR8&s@0CCU&3;+NC0M5(+0C9Z)0000000000000000000000000 z0000DfwLMKE*pWV3>=((24Db-2mv+%Bmy%5%3)a{DaEH<;dGx?3x0^xpcAx=ynX6PXYBz}BI9MhMG z8xkevM1Y8aM_hXE`|=OZ6PxdM?(l%XgsirnnwjVSiaUKxcMsqD+C3qx-y}_SqWvZQ zJn7c|wwZB{@6XnW5nlbDFk$v<>$&Cb|4(y%<+n4#$%gql{4~?aP%&Cxeh{k#3ueC^Xeh<3b*g-E zSgH6@F+`+TC;{4FbziajvUk{@r#TI|{C}7KyQ6lMu%9c%tO8UGYY9|TSVzsEV$?Jh zbEsI53X`g3UVX>jvHgUb{tIowcCSkfsHRYHnHdumr4wW7e6d{mkM8!feakLBh%M@0 z5U*5T&!ZI4>MBHJ>a%LPB=AA+YrEQkdW|6=O6wcyR)n`*YZc*`?`E@V;;k(Xl8P>^#cIpjWtN__wQ zzrw!#SP_6O02fJJAaIem6#g#4EkInNb1biPNCGaBj=0N|6Ln6Ma~We%#pzu%?p3;U zS<98>?#9*ijz!hEG|&J4vVOW|PZ8A^`BzDVDPl`I+XYa|I(tcklWig5~Oa>v0IhMnI+o$c*tAFXHG+E>jkv&-k(?!fAy<37Zh$wvV zmTfw<@0qqz)8<{Jnj>d^N1*hX&T!4#lC9cYixFxset~`*3^LU=k^LZ(+;mv%2kMlXc%s2T7 zzva*TPgH^>ibR(^MhG=fIgc$!qxXlBi&1+|2h*J@fv(JL1b87Hrg0%hb*)kYNJVL7FviRG>8P+7af9*MyH~4(52`abSt_CJ&K+~ucG(Rr|28> zGy2CU8!H=Y85^0r%L4+yf9LBN2GWN$)P!t2T5EO^@@`h?mCk(s(JIB$ivLw>;&eT+3vR<=EgjrVhd#7d&lp zJHB6oQ}+WvBWJeYG^N97J00lr?Noewy*b+^vKS<^#C9FesX4w=muqrbPJ=SenE_;> zWI3cf%8zfZX^B~rgyL#D2P9paHO21Uc676UMis+$-_ER=&2g$*&2v+Y`__oSkZE{x zjyaPK$EL>dJ)(nP$8|jE-FJ)OxQ^T7lF$;@bv&o$ z_>{5ZYEK>^=Qcn&&|bLAove&puisW3->DINFRpfrLDIF?T#fd&n89)EN!%oUl6sOL zNh3*f*}li(+DZH*^&~-(R+7dtoq`j%vpE|Nq=GGFMJA9U1O$)?2#P=;1QkF=KnNfeSeh5T94SIT0GWWG2n0e< z0b~S(0Ma#`0#ge{ZL`__6u&@K#t~0bT$AWOiej8{L7PzWV54@DM5L4@RT+=g?_7*a zvZBCgA_$g5SQ9D9^G+G=^#mU=Z-it$IhqqmSxgyKx};2!MBub1cvOTkFR~0v`n^|- zXo%DOsXMdj^{|i>rxA@~ECe4edif|=LCxWb5>X%O>mokPGzgAPNgM_K30*D+WZaC1 zrF@LAgpgd)3`-Jm(GkaYKA8)SSv%gX3whQ(uQ-ZlU0=UBb8M$2lf838(lipIgTuE# zM-$&UwiFz@e!fu_j<$Gjv@9E=vS_<-(e@SFwERkUv@!!nJfosAi?TsJRIN19v?6kC zc}0+%h#?YHudcSKcu!n5t*@l4tgNN1t8B0Ato+yNJ!KCiQmDd|eU-bEhn44)50r1z zHC0dTsw-5f9-zN>z!t)=ayP1hoAP?Or;+F{zU+F9CT+SA$#+Uwe9+Bf<% zeW5P(QT;Ie82uFeJpE$*R{dW6LH#BD9sN`NEB$-@w^FmTv~)n}@zRH-&r08yel7i9 z-k`imIVkT@o>R`sL**mO7nHBkL&ynomK2gQ@`$`3pU4mLFa3==Q#aa#4y7Y#ES*aC z(<3yO7SJMEM(@*S^bLJa?NT+Vy3}6kDvgllN{ggSX`6IFIxk(3-b>%4e`PM$mR)3b zxvAVvw#xlwn;a>RmdDA7a+*9(ULkLkGv%G~8Tq<=M}8*1kw44dWxJv(?n*V3u=i*8awAAgjwQpBM%$>z{#((T~GVIY}61N9y> z*NxOh%(&(*xP865;MK4*)&^(r>MK%&t~SpJ$-d=!`|`0zuL=ePZZ%)rO&*TzQi_rZ zzRUgXLw1%S~t3Y$~E+Kc%jwsDio0Fm_I2tG2oAgfMs=qq!%-86dia6 z(Z*31Rl~3TkV8?K)S?A?jbL9xk(QzJbOtd{2kJo=oj?CUF5#*W2$b5m<=Dt#nzt)q z#EAHWeh~?q_Kw#F#*qPAPQ{j*o?Xbh&CwgH&ty;?oWXmn;948jL3|5EsT7^c88-h% za4&&2XpWu`!?o3$mt}8ty&HJh%QG;zYqzVxkC!Z+zhH^E6}sjMs0D)-ELgB`;ljEY zU7?-Wy(d3!cT_|~bo9Uh(YsHWRV|hEq{A53`ZA;CKBkqo&lMqz2hpXk2>4&XNpu2^ zA)wN{9r43P#3u}lOxV0Ul))aGI zJUO_2Yu0ho@omXt5+_C{BVa?65?!5#YcKMJuI6NC=j5bh$MVJO&R)@p#S7>Rwb2>9I5hGR*ocDy6q-O2RDf~~sun(h z+ECl_pEz}i^5ume=pFIDr7%=`4|XkK~w(SeN{M-KGX)fAS@_#gpQd4lLghcdMo z)=A)cBk$SMQv=(Yxi*jWVH_4|Tg5-)7cQQlgKAViJbqW)#?f^Pa#HgDI24vqch7e+ zn8vjtizbX;Fy4gCuv&z`N*CL_9I21RYUDJ{c}HetX3ohr?O~2i;G*4UD^|>#x5A9o z9Wn{pJ-qm~tNN6o4cek7L~`v(LGDA{LI)=PIb=xU<~*~7c06Z$WadUmGsfdEa`R3R zBOr3V;CDn!Kf$$oX`)#a;{{op7BAai{+n_8hpvJT7<=gS>3xUt^Y@J$Jb3KL0Xp{< zx+Aihn|KOUxmJEv^oU|No$i4Xxp9?ox?tskRV!UVa7>10hM$#jqolLoNU;f@1O>(l zca~+0ga5VDIlJaBe2xN}mv?D1-w2>W|O?-c;+Owhv#v#-md4XZp zPWmo)*ZZ8Eb7$@~Uy_!OjawXN8pI%y!(!vp1z`z}4;rIiNa(-Y+Z572C3uv6eXl5n zof6c%mV2$@gsrU^i;7{V1U0YZ-mW-fYiq`$VknZJR`;7&#jL_}cuiVStibC8u6wUw z@JfRF_=<4*s%DI1eNYY3TBzEKtMQC~e;w|1RKK#1aLA(29|W|4{?H!Al_SR0+OGnb%NuY0JPIXFELycpuY zQFK}N(*sPVfkNxU<}t2?zz}I6J1wc9EKlehJ<-O&Mxpf&tb=W^jXT!C*18IA?`}c$ zOs4ZuM#C*4qf1j3W^Axqmor|fK@yC=g5#l?^tt$OS=o`XL0RVWyU2rROI{08qxybW zU&m&>f^J|^3twYfsigktX?Z+f@P)$Y%{bJthiQAnZl*{7`bQhXjQ#$S+TNJt zgactU1i(zvm12Di#rhCRx>B&-yNNvgy2)%dFIW_yIrH4AN)Cb+||*zn_%nF_0525Cca8YU55D4}U(&pznn z$~9PX0oPEjVW6Wo{$cbceJIRyESVS`(=#HW=kmH1>F12HQ@KzTCK|_F)MElXs%;j0 z7%ZKvL5-g!P+vFZc{}DwjeJJ5?j5@Ia;n)nj&$DpXw(~%YFLd}EqL&h)S)^Ig0y1C zB!3wAOD#9JlzHa#s*9%blVjWHTsz8k2=Ik`&1f({n#v@jrO_ac@e5)x;|+{>C(M8~ zQ-!K^qF4p}pvqJ6fMON!Bitwn7rYu;&;vXm2!g-^Jir1L@IVg?!XWfO547lfu84Mg zT_KDS-i{FxM*#sKXhCjNSePxUzpgeA9>sPm&k`9z=h1&>Y~Q{yV>gFDJl+>-y7E06 z(-RW@PLJYGD)L{w>YLv|$MkCG0-oSPPaR1K3mcv?L|5&#S1}$M6FjnqiQBCjx2uJW z`%_hVmwV{_bTdCCx-+if8HYv;ZLc^d!5Kp%4sDKbgQmsWcJYGo#PqbJq>Qv}+}|;W zUlt+TU3M`JHuMBfw4n_>V;Fd%C)jwo-5Nq2u8tC7loYZ{lHqEU5Y?WsSa736a|b6< zB-BxGewE-Lw|IjxCp{@QauXQ8ZLjBqsjvp3VFu|!aR4S0P91Zcb{^a64_)))C-krT zNB>;1L*`9ON8F>~r21l<-h<{ZPx>oWd40q!{l8H3&)Tq#gsgOQ>`3l;PdnK&R(w1)EUU_;Ym#IHQ zE`9uXKAcXUJ?$R0X8VmUhV?8rHsoK|5x;>QGH+NqPBg2wqRX|l-Y;iXv^qZxYpe6q z@I`CAU(VEitzRGgJ#nJ({WkjPg$g;cSE`sRX~>Vc1a7qe|+PI{cyTBI$h>yI2!(# zSx2k-&2@Bve@I6c_yx6)0yZLJVUv<^!d77dq!gJTY)2+RYFL0w7Sg~DWGd_wnFhO& z=|WnOnTqtt9LNBBl+1B^Tf(a#5%#5`oIdUy2z} zMdXUXEvN>!4Y?uAg!?cHs>2GXA#zJm3%LsqkbA;xsEs@@r~?lTd16pkSp$GJWMBqVxP|yrw z;2)3-27&?YATj(Cia{f20)?OmIKp`73L)qK{h*`J2iiklC=LPW4Fb)fEtsf9&>FQE zT0kq*5{n?LfpxGHlAu;YQV65g2#Zkbpef8nt+%mY0BXBSa?mR8eb)eWRynI zM4Cw_86?YOqwJL3a$HWzdATb0<+;3+Px4#YDv`>r3aIj`u4<*)sCjC!TBY`>1L}mj zsIIAx>a+T3ScYT7GtwGajao(>qlwYp=xvNJmK$4)ZN@?4jB&-dZ@f0X$JC8!9kVuO zear#VHa#|*0rf^> z&>XZ9tw$Ttdt4Nk#?^6M+!~L;>9hrp7Lyb03XBW@HPA{zt11?S6Wsr zk5*hOtJTz6Xydh|+G=f|c1$~?-PZ1E&-CJYNxhRkLZ72=*3atK^{4tTBbnhEp3%%$ zW2`fF8oQ0X#!=&|nZoR4?lAY8C(Kjk8S|X^%6wzKvyxhtmE9_C)v_8}{j6Qq8SA2T z*T!}gyS2U4zHNVWDmX)((avONwzJ4t?yPe*JG-3y&JpLNb2mm}rDKg^<73BScc372 zhdwX_M!_VQ0SjOmtcA_66ZXS#I1kt1Eb3f;etQYL6kaM1dzv@U`|8*87x=sV1O8k8Q_wCr9VQEdFkRRv z>>CaZH-)Fd>rsj*j7mh!qfyanJv{hOa7}PsNUxB}kY^!AXp_)Rq1mDPLTf{xhSd*? z3QGzb6!ue?FRV7~kMPLw{^8$8d>Sz|VohZG$VrjcqUuF`64fZGMN~pmZqyS;eMhh( z$`S2|aU?jpJCYsgj=qk3#~{Z@$27-xj#-W}$3n*n$6CiG#}3Cn$6?1A$9cyU$4$pW z$4kfW5u&-iQ{&pV6RB2?`=_H;=>FPY$T2?u*6u##HEWvFU%)YcDERqroRq^c&^yUvqgh_{2{BSUZfi&Tx+7rP;DKdwB1>6tEjI zy-f$7*loEi^RIdE1Cz{^ zT3etn@j(($uz6SOfCoXPFcEoNYmb~tHf~9MCfXCa!6t?NRrb68$jhY74R3-;VvC=jehKtU@n#S2oi`5Ej$(+eYJS6B z22@2=ey*Z&MWu9oD&1?1Dy54?`p?Nox{=TtRZ7>V(!JJbykl(20!kN+@{x4I-5S-k zKmmGJaZc+_4V~Q3_?Q|xxncLTb4FP$-4q}5&00}*PUb+auNfaxeetoZ-qbxy;9L&$ zaV`OB{!c^g9A1&xWtMBTy2mN2lYo?b{MNYys7nP*WbYD~xE-IfZ3&Q_TWAMh_p;5a z_t|xi(@a)I^&gj&n~Lf$I9^m+M!|R*Nrsk(Ph>vHC0XD@wK2^s!C7XywiX|O>O;2a zs^*rba-6hJR(;50_*53Op*X)rLlug9BULFJ7b?*oP6L(XZ>2=#^k#`mAA$M<)QJk+ zlL%@Z?al%E(wPWq9UV;qdcfX9Q0qj6?nwkLdmFz1>O_U^=?&szE&K(VP6S#IT)Z4gkN7*GMr z-UbB$<!qS`8Wt@kls@KsU1J(t>}{??MY5p(=qTqgzRWp?Y5_N zuN->dgp_;w@~~ThBjCSzkKKN|v6suW!FcZEn1cN_<5MG5>V0J3rJGjT%avLW&oZCa zEd(xJ@tU1F^x(56xzE!3AMA~iOBK|mM8;nQen%G|+4vx)*am&1yH!tP!z#>S1a z=B~2~t6w&(v$d&%2hJ=EyqEUCs&;}h9=iaFQN8`KD5~Z^3_P2Idr&Z6sHF=Oe3pZ0wxXuegd`Al_Ra-$ z5iEi#s+-&ntH_tL7vSAadj3~nuKJ)A zl+$v{MVZQVkMxDigN;9Uu=qpzLRLj6O|o=H5h^lsfH#@tn(Zb_Gf;f()cOMTy8uH_ zCHoDJM1E^7I`fGN;2+*L#5|_KcjC70NrUf1SP9a4XBvD*l^IB8r?+opf3JA&@6WI? z+OKvWN8lfXaiV?+a$7Tf5XRByB>2v}LW)cBkwblMYP%>ErA=|WXq^e zvE>zS#vo+{HARu4&pL@MT>ZLzWq;!i#&G{jrR$E`yk~*WuT|FUvZrj4`VLM1xo z_^RKE?ZVv$+t6p+%4Xymxmsfo*`l%b@Omo0H_vV;@~e3l&eOYr>N221Zqws`6^}V| zd~z)0(98u`YTSs09Gc@rx^7fz9bglvIbs!omcCJH2aGTH9SWRikM9PXl>2ipAJ}@a zo|;2@Jy_n`D%mbdE07N6IRgXvQ~t0yH{&p>?;tB#ss1wxJ2tLz$TmCY@dYicbv3>J*X^3W@9)Vk zT4i58q^w|omEUje3bUzyic%4xEk(CNA(p8eC+(BhQ=DWt%tp=dfc~)#nzPpJcKB6k)!=)@rjFCQ=rC{rVwOY z^qla!2MhUUw~A`5hwL7OhfR+rN;l*E$VLBIT+_&VlD%@Z`Dqz`hilBAw9&;&SNIlg zU$e_DO5MhEr7)@F+k)%ZKONO+I1htp0L|B?;scpy9Rsn5dA~kbFR#sk9%i}b$ntf+ zbGl{f&T$I_7Q`)YeVelYSf`;PS94`cbl%OT;>wm1mFj&GpYqcmi;biXz?qOQf-)iH zgD#j)v%E;H*`syAop^mSx*^jn$%41ye=whRA3)tKXsLC^V;zDFwBb8PIXKnkG;L|YXXhF0~fv>O5J)^5S> z?ZqQE+IS&XN&`2K+jq!Hy}6H;n?HYLk)0M&rL^JOCE2~K{C?YCz^0Z_dvPrhsj=}e z6hxXn>m>4f9gF%Fc3MSvGxTngI8|T(-W2f9df^I*b}ykQc)U;e8{hRLV&kW zBhhv!s=S5vWw7>Q17n~R^2H~aKWJe7Z9Osf9BF@@ZQICdkeLsFA6e0)hWhnB%G{j1Spx#q5)jCgfOv-(x}-o{A0{r&1w3^&8Di56zdU-m_;^j}*squ4m`JKj2i_NvZO+s_va) z6MMwj)qdDm6jq>NtwC9BEG+*tZ~?ixYKFB2Wwo&g($8r8FTPZR)KgLS0()!8DoU@y zK6FupKNbst=I9qJe3T5@Lr9k5?yujRfRXuRjH#)d-y3IWkb3x(@uIW@$*`G zuFy_?E~!N#l-_EmH*Pt0-b(Mby~~08sk3`rk?mxGbbR^#qyG(jaV)({yeA{lp64oxY(J30x9zGeUfkGRK=~ct97EnvJLC#>+MLF%p2M^ z951RZqhP#f{7(ZH&`R@HZ4|s(XrTsE?w+RkR=Q5rrdq3ej6Ozy!g(1PE#}$OA7Cxy zLY25yfktT==qeeeS}Utlpg$C&I#f*5J+pDB=%`;Whw4tVk@EC~^t(oJ+~f)ODzn{i zc2c<4G;Y#@C*45>hVq6|z-nbb8fBJTqOI~)Uc(R`lorc}AsICluC>rKlsU>D7I%3_!4 z(Q0*%Q#OW}>LCJ=D@Yd@nCz-I)rzXmLuTNU8@JEelN%;$-|8M3J*oHW{0uY$WMWV85RvpH=o$JS~t1_T;q z&_0n`kV)XL3&0u1<{ysJoy-*iHh+1e^H3~&7q`C+~FCDMwh$dys(7v0 zX;vvicSZe8jK81>H~meMmDb;8jUHn3rz}5o5l*@vS+ZphU43Xl@hu&3D6oPRbs&#p z`#HYw&=+$Vh2tjXYI|0#*nLR95??}f+a=uL2Uh!RCA)HSGDnU{G}Gd$~TqrXEy8Jy0a|vb=s_mk~|drP(r~v0bw4+gPsX z?qifkyOFn*`;mv;l;`EAf43!9jQ26CtL60zH+EZYzpS4A@W4x;tuE^DfLUGR;F`?g zlGQ(WXA!c7AB^hAs{#zuG?)(5p-u}8Bzh=Op-zPkszatuW-Zl0r$U1g{YX^MX<;qu zz|^f3p>L{gq5gcs3R9^5{Jyu|=vYJ=rNUA@)^fHc-0jG~y zZu;pOv%GFlJKmA(a<i^# zZ?kk`Sl6R1Y!uNZmc1ky5vY6X*o`fwnusYr-lA(p)G*UGN; zhpBS-_?UhUTc?EGXTCO*MoCdyZl$BH4G}LPqZL}UflLvmx|mzG*=;y|XjRVPzwmF_ z35L9_ynwfPz-W-%zE^y^$TLaTCYaQb(xf{wB9J28r4+wRgD^`=&E9hQ^uaABOmK>o z)jX8U~)~RrM#&7k1&$4(wTT~eoV=XW;pP|PCjOphItrwl3hJG%d}_?M9&3*Negv5hmD&{& zZ0jB-QIHIf0 zlN!*j3X0dAmlEE+Dm`^!jiK%oUg2%c_jA$BV|AO$ysVJV=beQ&l!gee6eZIT%PW=3 zh!)X^wxT8V4>pv0#mbGG&M4IIQWVrmUy*;OF>CO;yj{NqQ&<_t^gyZD^TV+{xcx+bx zgangj&@4%HQOA zs5i}#uFLoiPo$z9iw~aEEk~%IxAI(kVW7he=}Q_bI1#66;m zLfhY~Ql*R?0ae<@@`V%cwNsn7pDfd%wYW$PsbPy)(qgu!oEl~H9W|(bPn|;FM57g2 zy{Sa#)j?$tmSsBb^D-RJeAgMUwr7qM0{v5&o92GCTLb|pK#pt+>N1q z8BN46jPJv5aWqkibKt}@;^{m+0<;Rqy117yol z!BphV<(&e)LCE$3#&*kirhPm_|BnrZ31kB&WX^FX(w8`3Y=cqP>>;#+D*WXth)V<}Ts zq3B`IlB~csSOf)JS3_fW*T#nMu-J{^VU4lfA`1+evq&K?owiWH_6Wy;$y=nTRxDJE zzohI72<6)7lQ2Mmq*j{|Er9S5NESlxTuWskJ+TGS~4Q`ilD^*g|+QtW&dCGc15TN?aS=7Oo8|dkzaM z{u^xT&4rcc{9i?}9yK_VBmO4YpJpEjIE6DY9^WPwK2d`!b2qEv z>yw(clUFM4+VAX>%EH^ydEhHixrL}4i~{C}hQ%KX zaS8`a-e3nPldb32u*SsrBp!PrVm46vbs&?c(KELXRqp(1J=f-TC8=$%=Igy#Np4cR z-|2Bk!=f3ETefi~l>V;*$%JalamMGyvn{n8N_tRF9!IqvBwG1OJpzyU+z_n;yLhk% z2Q@gXf|F$5aRJVQ&vAjB1&hhPV~RHLIi_IoOfdF@-zt3g z4DfkerN9g$$?8$u!|gr2GD~ok0xi17wvoJt%M$#X&?DgA*jSPtAjFb=@K3Hiw>hIi zV>YLi=ycT8#F*33d~{51W}>Jwr=^*7RA$odm?(ABnV73d8lA2r&1|MJE1PoL`SzyM zS1U!cuB1d~riv;~H@B-%wtZ+CeE87h=j5dKb8^xTKGd)h`7`)&z^!=1ed|3BTfYce$;{1j^0rf>@^Ql7k~Mgh(f&EAcAF_ESyigN8wpdsvtDU@ z!N5ny?v4LPEnC5RZm7BL#vu)U%dEX(xH_bUwQ@2Ce`ZFqTGQ95ePg9n2Q}zkUs-j~cYVg%E7SGNhm3o5g`gZFDiwTYLWVAZ;G;vWMWb2M+3!H^7O=pm zfL7DpyrJR0)4-=dfj%^Q01O9&?GFhTe2W0-_#XR>eJwO$XXxAsdJiz+9sOzU#&Bblbsl*dZGt zyUHfU%Yp}u5?;p`xK4S{u)EeR7t7o7cDya$jIVLJNnL3+lx!oPap(nJdh|jsJ$Rv) z3B16IW8) z1?Hj?M+_#}KtnEGPF92(iI(eE;OS}oiWKLoK$Mn^3M{A9v3+6Xf!*2748xYZ2Y!5Mf5XHcsdG6&XDW`^}32GYaHb8s!CW611c z;|$(`8E_{_;{ATj$e;Ir<65w8fl}QL#Mb!nbx#-TLgxXwTZr#$GzT$GUqRjO52`a8{ zY|9-UB+y<`{3GtQ*N`Z;;Rz1hK@B{(nT$JAS~c>Qav~Mcm2&H`8oIED(+3rntry9c zB$2unUgoJ)qdTgRM>h=%8_9k0XU;JsG%`-?j0@>;(!&)p3XWkvaUDu z5&!Du&6h8|ZY~q$sd$_Vjj!wNt*PtjYmSMi79%!GF~K~UY8xyyUE3^Pt8Q-U<9ll2 zLPFx>LPZu=V(hdJ8RK<@pad(+7pkK}^+k`etW6AlRJ1y7Co0iu@(a~LUqi;)E7olN zJ(Rd1%We4kec$)z5HnJ<)LbE{h{)-cD~s}vpZm~ zMdH>Q4sO{j>Y0mm^+%d`m@$r@16*Oe_Hv%mWI(fc=nzpTkUC zAzqwZz}ascz7JM|Js&zvdpC88sLflw2&eEYOfv|^X^}}aZNJ%?oh3;I0l@azV2lF6 z$VJ+wMhYx^Y*}z;-0IvCVJCJ{D;Ow*LP{PVBI67cVj;!W!cy{x^Y^ES4J|L07dJ~} zFHq5{PMcfz`wi1Iy!>yp08K6hJ=pg24<-r!3lbJM%nfdxM5R_$LO!KPhh-1M!~}WMc`3 z3!wzY|BVPNp;-8uEFgr#&A2>Qz(jDu#gIADedKhrSj6nw5Sr)3V=UUkOo)JRSOl{` z4?bXrJbL4o(HkP8_P4Z%kRGs!DauJaW8jy1=~k?`se9d2eYW(H7<8gBr<1>Ns}Gid zGky*_+$!#uH*sMbs{8v}s=K?IHwOkvo5MxcXi=W$mi+BNu#oznvk)7o~{H<2HI~)%fw7RWRe_ zaB)kEX55X!@*A9eVB3SPD+7;;Mc9N{)Ta2z9X>ppODZo5*h%Bml`C;7e)N0=Ocmvy zOqRPaZStVUet<4WhX)||i#nrp?>M25c6 zfuE=IbvB$zySPO(q-PnT)l2Tg-c8j!On#K^rw)tG@?RZVG_qY2mPWZ0o14BAN zd9>1M?d%?F!AfKwDl8I*5^B>FphYc6Ac6Lh0wmOe7A0}g|D*tO$Q`duRx) z=KgJ$f;lGi4gegCUPgKd(Qec=hHLo+46`vL8A7mLA=HDAd<=nXS%nNE zx}=+iV=)Zluse2#FpPsNsPNC3;v#+3%IuZX{07n4+4Hip^>!T_4G#6I-faK1j(Ya8 z`8g{b&_Q06le5ZcT%)UUa#lfi41+ivtHT&{YS=-ZnVmh;X-3nT**U9p=pe6HoRhQ6 zdF}Gg`(OCI_<4BxWj!ZrarSJ7YSbyoH)?2aJv|wog%c?ZGH`;{NuWP=(%?`&9-?YE z9N8t>P;7~LBSY{LN&cjT|zaznMTn9p_*PySSM7|i}#G8u0l1v zna73cjXcgwFUIy~s_Dh@#<_t^HNBW;JV+6$>BR#(m}+_x|Lvfir8BWH*&|GESDdqs znO-Dondxn~h?)NGbwb6x5Zj-r=!WUX8GeB(YZM8+kF_O+yyy)J+R5SMDB8GoLdE?wbrs7_=@gPO0c+tIssngdO^VWcC&FeK3 zhpdsETk8B@#=fu&he;I;ueQH}<)+o*W1*G`mLSUn%S_7xOO9o;Wv}J1<)r1T<+8!oedE@{7X zPP!)DmR?A&rT5Y&2_&>BHecKKwisJ8Taqo!*4fs}w$8T2cFLA(yJ7ob5c(`NP^f7w|mEqO!v5P znVI9pxifd@T->)vCIpHfe>YMI)v`GBn>_f2s2&m23bhoi-fo-dx?I9q&f?2-Ok zoQ)wsJH37U&>W)-yWZuyq=>>)44s+w;yJ$mlm<)R@k?swIeaLcbF! zVi-m_lWk4JWnp#*5LzpN!O;SO^?X}30n*FZrz~zc(|L2zjOkp9VvXhrEsXJz49b~P z16WOQ|N1V^F(?0-Hez_QumkHpro*6ye*O(TAE)X@ayJnP6}UD<#0U;i-~NCo-RRdU zmN``|nqAJ@k$e8qgf3g0SN46iDB zM}zC+hWcRy+IkAEWoIp1kgdN5$0>p3u*-r43+B(CUv+^zJ#$XRSVu!oyXP-Fw;nxA zxfSok(YDUR4xT-G;6QHPfnhy+4jbA@SK}Fmf((Cpcx^cnIWW@LbNfDsMSr=ldJL*O5jWj^5De zxU+R`i^=Hg;IJG#h3J7r@Vd|pD|PYv!XIS}!3eOR1hu%<<@dG`=%f$|ZjwMT4BaFR z`Zc}%%OzQrug^a;W3*#bw<%#OoE>FoIP=KGi;fe6_olCMa;*VvFqGv}o-FWYA>8~y z@SXxg`84PeE^4Ltv#!i=m__7fiHq`*)%680giB3@|4`A5@yQIYj%9FF=IP?cX)Cw# z#}u2uNiv3E7x*@xspACXm@x!T-h<<^s)4`3!QbRWEeUEUc!?{4aqTQ^N4ijZ#9o#r zxMmi~ro$*AsVkXLM0q3XkCn~|j?K)Em}GoxNA%5=WPZeE#z)eO@_V8Hni6<>b>8JX5bpC2b{*&JvCxsIatYmNi zx==1-nO}y1pWAs|4xig&&PxY}9#{+Fu@8qnP-`dS59!yTg5zIc_801O4Vri%7)HQ| zCZ=6t&_<*(T z(YR$7+aDSh$XM<{Q?TaW+H?DeGi~?fVfXyFJ3?Tkrkj|C+e%$Sgi$bAXbot89RHH> zIq+__x%aVR;2+6+%Rn7I0InG;wH88~$!;5gvO2)1Z_Ep48Vp*( za1iC!>7zZ9^ZGkE^e!2&OT9D#t$$`t6@&4^OT@p1Q20bRec(xM&@d&bvCdnjjY%1xb1bj7joNa&@^Bhl+z@=h z{_y$l-&%Ok*NMk$^}Aoasec7cesX?5LR#mv#=07d64!EBsd}$Oh2fN!y6$lqJHa^8 zfWk1GgFoQfmje1g7Qw7S$daKCW)W4xwP~=Ha;+W(D<+`zm3T_Q3JHYw($1vCw_&P1 zG`5qL(bt|!GOhCc?W=!@ch-jor_qfx&2DNpTjcQyJ6ZXw1vyICY}a*)v9i&9a)r=A z`<18UF#U2(Nh#}CtCJb8b~;|SUdN7Bq$VaEVBW{@B9U!5vghA=)a(lsq#3j=RxF>b z;2UlGc7@$=dbE6+qIs~F_sNTAzv2qHIE~a9d_H#(98uDBVAvhz^E$O# zZw9!1x z|9X+3M5Z02FVAj&{aScC$&BoX#JoHAEn7?$?2oC>kI@?bbO_qCnW!x`7LyXijOU57 zuG64rf&FTBZ5L!k_NP1>VF8wC<~!CLigf;0&R@#!)Qft?|6pEbWE<(q zc?ud|N=N80v7v<8Oe0Fpr6$@?J3$*dPlz&8lWTvDPj38~86HprYFT%jSrR^J6%$k3 z3Z?X31glpzLKaE0YqM+?S(irQC~}q{y=`O)wW4#TO{+y0HU((H+ICQk4lgj&fpf||cg z|E~ZKnuQ=h&J70h*MSHEd;kGH!gtWY51|tX!d;yk2)b&z0B{lPp1>R3esC$PQLiZ& zx{Mm!7sC2>8rmOf{04om0GxvhTsMLV2nZ!Ltrjo^oGO4D_*fse4yu7KB!IrSb#)8m zlvk(R z>Y;IA*QggUBV#_qp4_XC65<}JZ4y3!kh&2>5clqE>fV6gmu?UOQIG&>kOM_f1L}bl zT0(p13Ibf^_UTg>?sG@{v}J#o+6xSzOKqRY&CYn56$S=q2Efa?gR5NSSNT`&V5{vI zWB<9D+*dD$Nnp(W-8~${KZ+dazF$teptxJc3aeTW?B8FX6YWRjmi-V z)2e?*Y*;~U=U0N7uHSljNuB1Y!N@2z8Zy%5d{(tpxaDwub&sD;7_7(C3&eCyo9vVR ztGm@LqFbSsT2gbv&ANFNwmNpBrp(QwxAmtyFXurKhr9R%^_T|vPqGHZl|8?!Xy>T+ zRkkm`WR=Ch_&^P7Or@!eo2ljPrC<;UW@&@OX`~mjbEr%9I|r1e_gxg#lO|JD3-_)& zu!*2PhhV4qdcmuQa|7O1Lr=tsp8+5;k1!z-QK(YF9y52-QVB`KHq{qzas%z2s7Kz! zc!7PnRTO*$Y7j6=E$J#$d4h2&6=@fQbAe{cBi5UuF2^8SZE9ls7eNGDBT4&p>OMtv z>a|Ksq@u(o*j}sder%q7r!dhXX-{HZG-B2m=gy-Yk;y17q&2B-A=A=2e0qg8!^Enl z))oKjv|g)N7d4ulcWW^!^r<#=C-p2u>V`<>RclQ_DrUzcFm0GoRn2|Xi(QD z>>9caW|jF?H$r$E&)LaDd*S9uPdJ#6%0<3cii=m$6Cj5jgWAAp&vqzf{L;>F(;ge< z;u$r`q77&%;YHv8&uX$>)rj#ByLPOEx5G7X+(6u4K3J-gaASQJtEsY*B?U;8ZIaxg zd{tkY!ngURQFofX9dU_RNjqIj_{13KQoXuVRXTRwmwt;^(^C10zk=W=$^E&;ObC#* zHInO81sLx{{G}Nw6B_kJmL{y=X+XFS7zP1(xJWIzbsI~Cm$DQADloaV ztiU4i={sAM?4t&lbG5BG5Z;4*w=}FS?Bck*aL?ml4yTO*ElN3WNxS_iBw=vM!Ztj#1Ob+oVJq zsF;*W6LANWlwcnzE*PpT9;Ry4Ih|T;gigar>R3c(OOaJU$z#RaaIt#_=Fk8c62AGI z@~LawZrQ54qBiWz3w&g6HvcIE-sGHA!)>aATFdT0Q*>GO!uwq`p6EW8V4F23j!QmwCkQ`;R;_;+Y)!} zrkSm($6_*3*I&^f#bJohz9qx93u!-+Sh*e99ooZ$Jn~pU{L=N7hgAplDAe&$tM2Mf zC_nS?=P0mA=^Akn*C%`ue@r7I&9+ZXvxGtTpeqd?%(XACl0ojja!fdTfDC#=?+W6a zsXT->$9NW&u<1d}IHEd})UAQt%BT}UpyU3=r`{^R zTqhE4Fo+sr8G%tPICROFWkS8ac$bA6)Ya3|!|#Yy)nE#xW$A+u4f5gTa3?5=U-K9k zHBnO5RAEq$Ttn6?>lQ;lrKG!a#Te3*+psHSH?2xFspnnPqwCiS`6^XmL zX~wP2MCzvNvTYVqftpAOL84cM<$mcRTfMpm59+#*us2kQ4`EL2;SNw{bM$}l7bn%X zZAii`?#q{wZ3Q{I*P7#-+s{>`qL!+96=}uwh`5Up((YqDz(hfcgXf?cbWEyknF^=~ zl@t?-1ghBN%%gQYKbq7zm>71@o!wM-p;g#2ioVRuRH9cgPb@Me4L{93D)NHrUkDL3 z!jia*5RQ_DNdX7jaSmWAG@MVnp6S;_F)cRz|GicbTe%j}uDeou-&cGMEnt%Wm2IgT zEE?2fI^Zs=?|?^NI@HqyA}NF;`7mM|!|EHNaK+#1#GR12dwone6d5{)`(L~8Yfj;=Sycz^#oEx1)U-)mnSN02%^kFJk>sj zs6>S{n`0TJ&4mllY(TkO2xwZ|kbyMKG@+n@vw_uZ%z2bfBtn&P3|yEHo*vwD$V81@ z@*Rp%WndeqXNUlMNQg$0=2<07_jep$R%lvS04?H0?4cLpETNE=gV&EiSQv``TKAa& z1;IX%3Hk=sgOo(LU$r^kmYt>u7h(;Fy8xY=?4RKp)F#f;-jSLq5%{}@CSu_gwooR) zW0d?1VdmH+2O=8(1p`!7F+ke>YNv+s^Yd1Zz?eq&p(z|uVS$dUDq~WUf-8tEDzr%h zrw(2PVR(}s%$?&RNVs%fR#&)b^P!5)T$?bNEc9l4rrscK(w~^E)uw6JK(L^`_Gyu3 zEb1tk7*4p9#*C@uORX|g&?+2ML+0Zv>y1)aciM*s)l-@fXt-JPo9(YrPDr{cLys+s zQ!?tLU7{$)CnUgd=w*%{hUbi2LMao5N;KqRLY^rw*o|}HYO6N9j+#$8!gR+H@~hhZDE3XkR5AHbv#(Y~5SnB`{Oh3I#sEc{Z^ik7FGTh( z6KWzDu#fiRAr75zovUmwqVf>$MwhDMLEaXKv8&N5PD;q9t#&e8*8AO%BanRP!MTs# ziUE?=h2&=p+CmN{!3yxcXNpc99BuiBE5SP+=T$XNdRl31F zKQ630C?RRhUpwx1`Kc??|G4sea&`J2>nXKoJtpCji~+JPy(Yci-DHt0i^;-DvaaRV zq#I6NgBE6qd`ziP$BbBvW{0o<+e{os*pIdN&FG8j4Y`oEG7)x(h z&isWd)9<;OQuV8~J>!ns-(|WXv?Ghm){W|^PHOGc440+9XuhXml;nwLJK|B;PiO*n zj_&uCG(3^sPwY@yMTx5$D()v_KiHXf(V?+p9&wTyRZ)^5fQt58LF`e=Gd{8_gzTxe zl)uukfF#+;X6)Hrfd9C|?sW+rOnZ3Q)@o;aeU{ts9unkJ4dK(g2fHYUgvtH9bjA(DKzEPH+mdSu9v+UQ_X3zE%4cDe{+2ebY>qvyP5qzmh!LRzGj-YbBRso+H7k(+TFemcC#aY968>~ zMNcJr#>ja+H9aePj+{K+Q#_0G1vGh?*LV}9?x2U>=fl>ud(O?ieE9c~L{tJ@a z;lXWJ+}_EI^D@4WL~k^)H#)dCO7%vwdZPtvqox01TCaZEM^Dy5)PJ#uKJQ2Kax^j; zztYNR>QJmUnvtGIZ(CFot)vx>jE;Z0W3|yK_l?eq)vkC!blG!yO>|RqM|9uQ8>@{T z_Uh+F6+Ww5jYNNf&`(SyWCHv+PZg{FuWAx`U7^{sAJhoVD`B^&` z#A;V8i#31t+r~P_ddB)cVYRWLE0)DZuY6+c;1yG`+9zyQY{BEIjV)c?>Sd1J95EBZ z1}`8uHUu7iqp2Y%ZejDX^ni+MdIR+gh*^N^PI-G;o*?LVrOgba2gE{D4JW22KL#Cqh07C z*6!}eN)88?xs13gTtmX0?j)N)p>0*u9_Yw4+B2P_NwJT897zKW9K|pE%F+BT9B0fp zXPRLKXPY$1Ip&zdx#nBKd6rquB{sDgm$}GAT;cbA&y_aVz*R_r^=@~EJKgObf3eX6 zrh_4bC}f6Y$R?2GTGxUP`VjhvbwobpGbDZ9=gD!M>&W#Lx02_Fen&I+xsNU(7UFaz zP{1*q2VB7Yl<)xG)8E#%;Y2&xnUn4208Vv~!??f^j^I*9If~01>sT&#f`@X2Q?24A zr#YQloaroXcaC$o(*-WzE`JBDvF49SE_Drhr+1P>4DFCI{?8Bm$WQ#tZ)z|yVWwGT zn`@q>R@lVmwy>qGkfgtOKnLtc+dg3(ai8`XGW3Cy23b%J9lVY!SYX@Q&h~b&qh0Lk zK!-ch(T;JP<2}rjGo0;Q=Q-b>2pQd|22mNZ#x?EgttZRv?jXBeW#l3m-|Mi_U7zwP z$b=-6LHksPKfWJ?XW~#xL>`kBc{uqCjr?vq{K20Sq{Y#*=UZg4tt~0Tx z+SdUNaTw0PvYueGAj`W~4y%<1e1~GwNR5Fc`OlJ|3 zsbM+QtmG7Cb2?{pI2UjQ$8Z(batb$cD`#*!w{tFcv6l0AkVm+b$9Rq_S8DrZJ6NrZbaV zX0eET7PFiJRpVzUVhQ<{Sw_-w%SqVGW+b%& zNl2y=26bHNC-jGuow#rjDT*m3wsEL)ic(66QAR6L0Av!l3Mok0Lkj&#Ep{q3(Mpq; zOv*z@KngM-6EYzQ*)SAxZvHTi;Z!O(jnk;+bgrO+E4h+VknVL{!F60mH8*ev)!fOQ zlyevNQ_TZBMir0qJQcja3smtUFH+4q)=|NFUZILtd6QObU<1``WDBj?%5K`ShxcgD z`@GKtKHw8N@+n_1kuUk4QueWrO8(DJr1+Vi8Ne?zlHyl>Wdy(R8^igXKS}Wye=&%^ z`G*w$@-GSg;{Ykrq)CX7sRpu)j0nbzG1RzmG+`Dgv&|-Mjya^vHJ3r=nMcZe^U1Kl z0umNlNMw=4q%5(7A(mQ7re&6qvfOe8TVVy6Hn9l}W?u+sV#U*u^ePva4M=#BTPW%AWS5+FtfzjJ@qcm3{3?h5hVLl>;0= zg##T(8wWXosc@2$ zC~>k=sJ7Bd%01YFsrC>LVXTLF7#*DIR9abO6&0pTG0ACG)7I%ur`j3LV5&2nMU}Ii zM_cDRpE4J?fNB@Hh=euP(9b0&4Zu4J;STty#OyP7Vp zaSc6O>ss=B*oPVABR)z~pYRC|w$3`5`lL_N+^2npmOksVjPiM(XT0lO&q!bPWybl6 zuTkvlZlIYP-AGg4atkfo>Q#6+ zOE%dQl1m}dns$glxKtXfE?7}OfCwUpK^*cR0Y#jt2*?C~D8ot%>z?gK*HB2(+FvE#Jh?r6qE8f^;EnBr2fTLj= zfg_?ofy*eO2ozg>*k!O;94(?naI}P$KoKDspGaVp#Mr4H2y+VC%mM7?NP3B0hM-sI z6?pV2y$YE>SY;+K9OOrv3LgXq6nv1NS;EJDMk)DVIND+$X2Aju1USfn>!BP+a2(EY z1V?Z@p-^5#@+n#}*0e+-mMG8)5?X z9j$`_b){4121Q{ffp)t<+i4eyl9ogO9Fbtwj>L9w0*1Lb4d+AR0$c#r%&($xIWC99 z6}SQ#SK>-g3Jq&7-pReYpnl3Dka!f2!NcQt0uoQ+N%(jQPeHc2Q{CH#z60+7!#in` z5p?39k0Zn<@CiitBtC_(?W9;Hu~s_!h@XJtr;zq`3$;K}tN-PE$FAvzOz1bmqaiz0 z-aQ6MXH(|Y?rBvy5QN}hI**S?(L;sv@@f;u24!Ml^JD5R*sR!l{G1o z$}D40g;t>qvy8-$3SZffCKaiYf=T20%`~9P44s&Xgkh5~ABkj4#F;+LLUF=AJ0n?S zJxVDgwegc?IV26CG+Qd!N1#WyqA|S~(RD;u8eJpB#}FB>y6~~ai2@7@qOYikjk^Rv zmw*r!XW8iZB3_boUJ_hF5hs!rahzPsK8cuVmWUQHH_z*diYc;)WhW6qwa{}LH4z~* z7O~_cS`x9*GEO2A62UJ@25fO6q&c}bjKzu2jYE`+1t7NdEBBYa9SpVYXhVZ?Kg@S5 z%*}8gTQr0_74FfTSB|x8H)mC0cz8WH9$rKETEo|e)xY7>V*Cz&fIvUFCiIX|iE`~z zXIP`Khf5GCQWnz_UL8?vOxYK zlv09dUJ~&*RT`&;Qc`e+v6||ZsGZn5iOgiDwmb2ANMMabK_L1peR*0?ijmq&X81pO0cISad{(# zr0NaNOZqn4xKN5QeVVkIZKjG*EhaU}7*EnSm`EaV0|t$&)umgaCrQZ+aqDJuE2+_L zOtV?6jLxp%<0bux{sgZdJ$IQRg#0-CVqn82J%B6BPy;Mkv>H@VAn~K%Nr(-b94d*B zASN%?tLcdvnoRp*!*>;j#f!(ntuw>tVw4@_D8?hm2ruKgc)tgR2C-Cy30M=g-ZipL zj3aO%PYqJTmt!baHVN#>R7*u8oP5o@xh13rXTT6p8@0iscIpIsV`}pKyLKMUckcKi zt2ZF26^-YYGXt|leEJE3Ab9Zt_DHmr{$dw4R&cRWi`7|6lr1FFE3&D-LwiwgHuu~_ z6Ys}LO!1TyPe%HifMVTDn|fG%Njy!vX!flX!*Xg^y)j;k(=HS(>=E3`x3o$`3#%`A zC)1-3`6=$cN*o&08qlrFpfvT$P>e!39T2BO6B^T8bPDBkXwos|dcOiXIVs3S>mYS( zClEoD0#=3nB(*(jImfR?-nG50Hnd&Lsc$?)8rIq-)2lK}j+jRzi0Fi^dP%O2-wZa! z`vgwUL+m>n@!=C!O?^n%6C>_!8hZtS9jP$h_p0kM;6R^Ot&tk?ymM*i=C_ZDAS>F@ zsA-@Rzw=%V0W|ft%0CQrSxi)!NNcWJ`_=3ynaU_YjC4lc({vq`7kC+%C!BmFAv7{q+6!d4u> zG|t5pxcQ+{cTFArZXFoHBo<&bw&5VEI1g9imMNyzIo+M!_RXDtUrr5gwQmV#mwuB@ z3}YeIU^@=s6r7K%aO;B+^Gv^47e-LQBCN#@9LA}*09WHSi#O%;o23}V3@pYv?8FhA zh6`~GZnxU&^GzGkjWNu`60FBA9L4Fl2-o5ci$4+d;XN3~EG#Wwwj0NA1}?^RxU)pf zN&RNMC}B31VI%h7IL^c+xE^;|^z_tk)`v3YU^zBnFHYbrT#6fTw|%}rf>!in0&}rq zpW?wMaW*c)jkpK*?%zLkCcpj2{T}zc*S*_^ecG3O`}2Q%@Zj{R{MU!3jvwWJ-1+;? zf6o+*63eU8qHEZHsCvq@7&U2*MOIj6i(L*lHmn{weg8pmri)$cb`N^mtKRLSK0o~C z)bXSH#gF~c@BMY>-)DLtg>s6+rm0g7PI(=A4J(^vfn~!r)w8Bg_cqyKpChK6?pznU z()GhzPdWWi)w|n+9`~%5z3JUP=;J;+{PL;Qv!=ao`o5p~wLkj%&VSF8j1mv?XY8Lk zQ`TwGrQfJYb1bsLy5W{Hj#rP!T@E;=>P+Xm)YWcu`|#c~&KMq%4|~${UiG&3`mj&> zysr+wea2a*o*{qe=YH$Y{<-u2Gkw9Zl3~M{)w8DkHr)n|n_-?MR@q=%_H@;44)DL~ zWTNlyZ_S@Y#{Uf*8CYbkw!^%kg`p`Pfs_2jMvKiY)-ATM*kG~6#fFQ^r!JPSU4nJ{ z&c5m~oQiXCDXzt>xEGJ&S-gsO;)C5!ZOVD{ukmO6n;5N^u@hKrSRU%X-xt`xJ9l2r zXD9t9{YU)={d-5A+VAam_xtQ1|L$j|JyU=Qb`tqbNn0nteO|@yMP1o4& zO_zsn>bUuDjO=!x$&JT;M+kq0AHQF1Xjtm0b+p=?jyK^A^OEPfx<`hvono78_Ir+p zIn^pt8?9C|hBdc4+d13IvcSJj%fj#$eKEYYe3D@uEit^3{?2X*kJCAAufq#BW~{9~ z>PEw(rJhv$-y^Y*J;T=lgzuq4w<)rl^1N*F;>&oqS8+&fO-=xqCZK4ka|7e4ei! zy!HmZIl1;8zVuL%yZ1W+H#|$=iT4mfS7MC64~Niv;q#i(mlQL3lPzIf>RwYYPZH1a zY|rsr&+~jQ@Io*0V()0g%QgLPF;q}SUj{RhN@h^Qk(|N>T*Dna%yX>c72e=2wz8ew zyu$|s_-(7eR{Vb=?%`hNC7xw1agXqNFZFCsB<_*k;ANiUNyI(M8@=3fJ(;*idy`jq zo~ID^7;pAU&-YZ~9_uY$@ad5sr&260dDcCYnf&qQm`CFoLg z8M+)@fv)84@c*Q)M%NIWK{hvrq)2=@H*gD8+#5pE&}{EAK+lU-dT;_UKEH zUsAej#SPwMCtLJ*5iU-7HXY!QzN;dfB)VGH=vrN;>ve-})J?isx9D{eyd2ew8@#$b zRcX0{rC&TGZ@p%IlU_9I0>W9fZX_3R)~js_FhX6EW|2}yZmx{~!Brd4>3ZUb$)o8< zaL8Pc@5zJiLv{vmhhA{1R|B~hr)O>1P>s@bUh}t&&ezRWVqe&Q&U1}jGZ%HCyVX7AKJ=P-#a_9W_6B-mycynX z@21#gu|0tw6bG>&9#jR%ARP<}o(z-47jaQn z<I;aVHnRWA-dp37H#yz1&z(^i$Q@>kZaT)pz&l|!(X728*= zUGZNRD}G(^?TQaq+`r;N__s@g4JZ}=jPbm>E32Uztx4K$Sj2p5ZEY=Pfwi}u*2gB; zB3ptV$S${Q?RvY@9<)cD4%+?pYM1skSgnh=z{OpKOSmMst19eR$(Y@n87 zIi6EEfs;9vv$%{4xtL41jhnccTR4};c#0>e<2j!66P{%||D&F5e8`J@Oa>^~Q$l2s`CMBFlDd*FI%W1}?wB!mZxQI4fO*^ioE!WV2>*>zz^xzJ8__Zwd^T^3l zLp6Y>Tx_P9$}joWYLhRQ+jcC}>Gs?AefLsY8P#;xUjx)nLo`yO6l%08l-1yZT2{k( z{R=Zf3H4N8cBqm?{Kx#yoh@)w2sSbvU=NI*nf*`*NtdSp6sYLbuC(m<`1ao;{fVFkkrin6mm$ z@>jZDHca_bZ0%LcOgo*(?~mM_2zD$v+eBEM`$uxNmYA}mHNkRE;aU-%!JZ^%f1{W6 zqpc63)$fQMF^%!bo6!YB=oo?aLp* zp>JOOC~~{L1X=4zgzxDAC)=0bY}NLWv_0D$GRFG+R&6J%%(5y}7w*kYe3v6@tD<~@ zW)ZIS{H79yL#4^ul}Gf}=ZM6MX&zaff&UCyg7k~2dLqt<4XGYKd1Qs(Nj(!AO_7T} zx_KCX`C;T@FGTYb?PUsL>1=KszIBTq6naqqm^Ubh1r#~EmC_<+k)EkEPE8YY6?8lK zB>LOS=yk96xnGs($?t+|gbBBX0v*zw*Y^GLSk2})#AN9Me6P^Y3&-qje0!H7dKX3# z`l?^Hso$lJ)#DmeOIA1bPqNYYG;8-TzTP8dNwQFM$y(JmNvw+Y*2#9Z!`Pc49=H1m z!=JTic+Z}7?a8{KcC6nsR(h0k9QQ{~kENm~kRi0vu1#rm%%W~YQO38G`vSv=v!P_wCT`q>C5h6*{k630w9)dqU3~ zZz2aI0-Ke|UideErK{eH>qf-g{~=tQh`WX&cVX4%#+R-nW6^y;F{6&8R2e(T*gS}S z#wKq+M&*${DRD$b-7&_~@az7D+r>4Shf#TiueKP)5xycA#nCa_Pid?1gtn4J&&b`* zC4=r<9D5M9m3CG6IDEYUG_{9uUpnYyXmwFc>4+*ewo)?vv*-^}HfSc0tTC?YaTtZz z7kL3^@i?#xaVM;llwC^3W|6gir0g>?UP#7%l&73e#&uPZi+x@pA8UK>DOXHrH}Rmj zAp4UQg!)NNy_k^I`}_Q!woo-KjRH`!;L9bF5W~8HzlTHp;@PaoWO~k#mOv9Fgl$| zz*7XING2kk0_JfR0pplkO13iu%9n5gr!wC(IQ@IB1U>0V4aYH;IWpTGqyLtxY61eA&4X8VF0{E31DVh1J#NuUG; z2Me}?AjEOo2@CX(0r?}4=7E_|xIBnep~-7fS&5H4Agc=UbGOLQ`+dL4|NFkn4>J0! zaase6o9V_(-Q9@R6bSV7m;q#WDIe54=lq-XacynUudL!2u*KYM6o(gLJABnNRfa>64NN92u0U6AAo1?&q z1lWAybOjk8G{JrcVCoTsd%N-}| zVvRrVHlH>SW^%YQ$>r#1dDN0=mD2AlEMCkkUJU{!MBv%|ao1vL&UHqGlN_DLsB-T} zuToRmx7=w``hKE|x@l?DzZ~Dbp1%N{iqS=~0U})V5;yy-#*D2ghG)8Neyw(vj3fC5 zO)WI|0>u)S{dmgZd%!&tJPn}l_ji+)|GJFVbdUWH)q_(!D=h;j*;IG-y5ejN#dIZ? zE(mqeL^K_W1U5B`SvvA-(-d=SMi43yn|Z-;i;6F=yt+KS;t5p%1sX zy!u+E`_NHB#zvLQq27LSlAgVm4-C?uSCaL;=%@a|Kj$VeL{D_<}{I z1d9b9UCDA&irb7$a~VAlH|f>USj4EqNZ-^sl9ymC-!)ChV<(gMA-uR{53|_*#)VOQOhl(Xm1lg^P3T zXUr~i9dO%G_`>}hC15U z5*ua9h$vLr2lD`kbz)@+DsyHrMuEQ zu(EA8@dZ9x6r@m06rpyj&e#^FilAeYG^C{N{BaZ+vRJr9#t5_=u>k@{Y=jwqmM!z@ z>hbRkW2V@c9%Wtj@^SY5!V4}6~2#(<8HF1o=`J1cu#&~aB5`^nT4M$N8Q!^84> z;=DH#|4hewqN86y6NNF6Vd78$#@xP8nxZ5LI^7f5&El|5&l{A!QRif$w}L|%Dxw%B zvV;}lnGnVTHmagX!R^iJovm?uy$+!Sqy3D^iP=ys34Sn45E2OfQGDkkZ83Y0a!cuq?lipiC9R3yO?BWv z+IS9g<5{T331GG+6KG6M27>IiMTL|=DWYKY7c7?oY!QNROTl!8V8pb3KB=yW2ZDIZ z&A2K8WGcl(B+~51if`4_7N91|w4!oSF-LV4*@{Rhl)>j}EeC=nlV-k-WeZ@|*PJ8O z&5D}n>v!UcFR~VB{GF=cceehge4Af%Ok;)ZB+wM?1LE-166EESQi!Jc(9@9m1@J0K zm`eh(nP&S#Ae>_rCko=Oco1j`P-vE8V+k-1l-_b^tiVo!|IfN2{WVuZK~iZj?`#dK z=t=dqMG&LH^TO-G!@|qLbk^2zxRd{DZVR(Xvlysn?~xs5 zCq3~UL}>gx|Kkfp!bxPjR*uCq{YfwMo$)48YF}tuFe!ohZ#b5WOC_a{CzB_mJGDaw z{2E9JP?iQG2U3BT1|vpNm0$)N30X+vhascD0t3rHLH76c5eENJhK2=}fz%RnWEcPq z^8cd@EX2sj2uakJ3>@?)sdgH3Bb{ZknvKg*Sk9vs5;!|y&D&O_TRPaE zMg=vDNX=#~sv~+YpHGv;Is`v8a=*_sH>?CTs0|}Vphw9lT?X+RcoIn+_6+-ErA2yiK>Q_gDXw@m1rM{|4)(`6lKD z<)-T9b%4w%Vpjx7QD_N0MucTv?+MXo$bd;G8f|e1cpsy}sBw)BKHT^q89-AT<#q7S zon9~MmN|>nL{L*HH37}kc3q`~5yWylK$DBn+2#R}&E#y1$nSzkQd%4wwRsXlLK=Q* z0q7A-k#Jg3)G(N>)~tLlO|E!+&qHuM&6;_!C&#O3hacwHo8);nqE9n4&ALxB#gH8X zD9WoojE1&B!qNDm7+sICT@Y$QzFCc-r5y6OOe_-H=U_m`I^T{C_VF+LpHnO)irUc1 zh0i(VUOn1@`Oz}}AhGN!>Cg!t7AaCsp%5kofGG`BCo=t%j?q4SCWs3njsp2ZY$8K+E>&qwW%(?q2qeLy_6!Fsg58$+ptxr+&-ft$gYBMe%RP2 zi|mErq;)LlpiU>X&$Ugr+sfDzXbPi)g;1hgdM1xcO=6cpLcRj69%&88)Q~U@#EAU- zz26fSW(60lZVQp(C%BNx#7uDG#rQif2Zp7kk|A`*+jFK}Cst)M)#YJzBu;8Um&)H| zqo_zFU#rSMy20ul)`lPVZG*3f`cA8rE5EYog{uQ02bc=*_tX+vnY`x8z3XAy*;VE2 zpr~}O6T58(6+F%LvP0BuN2%8yJ~U2?@7(qcMq`I_9E%X+2@njS$WjxFSlq$!q%MJb zKmk*Bzv${dAvVt9;>yg5wRs#QX?agj7#Zn#aPhJ53biFWayH8Wr>TDJ_rbOgurR-%#zp!wGNwH?Fp2Lne5Z?*t|L4XiVo>^sp(A)LYguu{K~`3y{cdb zfS0c%K<2oEw1=E-M&{WUl;q=pB+aQ#GNdO3#tiZb&!aZWA*PN~R-y?Z?c4)NL_m1m z)Vt%*GXPS&sa#DyhLcklb5*AR;Su)8w)6u{WHAI~bXiRgC-y zmNxVU4GN1h&{^H%9TXL=y?Ge>HCYS;8@MXHm)*;Wc3uC7_u9n))P)#{Bm4*~jl_*Ql=re-~c8le>fft@e*R%XP4IfXFsSbzb8E&43zukmIxd4Gpf02)xoV4ZR zX}rpq#e{4mBKQS4!oMFS*J;bm*Zp z&bIj5%+klmyW*<_gPQ!|FpAfFr&E<-D>mFhOUZJz``=+Mi*;*_TKo$Ksoe3gX%tmVMJk~VX(?jKv9IIm6^nYPmTq`Vbn3|K1Me>G{FmD!_moU$_@r=r zt~vNOS`Zhug38MGCH}d&8LV41o2{4sNO~G9%prXz>$q3s#wYs($IEwUAP*=mz$c9y z1hVuQJCdb@#YxZF_LkGz-fVNp=4pVuf`{Q(7GPF}I^Gj`BBq6r1OB1u4BqfyQX3gj zuF*K|gE=%56x?py@AY@c@kr$7C!_yeS8$5ZEzR5OtN<@Ccx zB6WeC*XU~KSy2{qR=_E+I+^=IVI*|SpSDBv^Ny@8gv8Zg#bnY=%R^-&Pa4Im& zrmK#o{*;e-tWY7$c~EYd`n#xeiy!8=W4M87{QP0S-ue+Et;r@mX&bh@zr1Zkak)0G z+aXHj(H>g4(Pnz>*zwY4JWW7liBOL+6?;tq|9B3E3c?DEVG_tXli$nB% zcRviV{G0=YHe!XP!EQW*%=^3MvM--oU|zG^@_7dz!IL)YK*a^3#CF^MTA@!+63s{x z`WLt}%<~|tiu~Ry@8el<0|+ zY_1(ocWr81lQ|~4KDj;)BO4!iXOcVN>mGho;?4}fuO&(=YBz{3_KhGXYL8PIPWg!7 zh2z1z`T{8tSr0iPWb2gao04ql&FmwtTRiJKF5ytiW$oT}t53(s4!1$_}fdyzzI! zYd$EtR-#IiR+=QBU!45A53AQAEr9Bzu*!Mem;2h2LU#M+LxEGyp8f%RG&?{ZbP8sg z=e{KOedCEe;vs$<9YQ=fp^qLeVS1FpV*mr^?*Qxe^DznS_LI1@cJo=}uPH0h2)}b= zrjw}MZ(MAI3XVKL6CsoQO&hOcR?nJKEjQ=EU0w?abwlT-+=7Be6ALh2WRI1{21wda z5~`ozn2u+;VU8(V#I?$&Im!uZSHW?iWG@n2E$#^(s?~jv4adl`&_(pm=r7V{M1-Bq z!F6h;FOOM*Bhwc&y=Iz!%w2?JftRenRX2Xziu53-z_?1GowHUw76u?MF6KyH%X2TrszNl?KnWb(@c7>{qamMB=_N-=Eic^jIc+ zlwN=E&rrCGRF5B;Kgmy@t=c$ki(G@tUb;vxpL5mjKLyLNNn9{=)>Of;CskbV%8 zWzKYV76`e^(af?aYU(8!r=r;;N?mSE*7~u}&I9xah1fnY*<-)#ao`VJcMSHwqyn~G|S9DO{8y3;Fx7{*IWyhRyb1_3%{C^KPM6X zR;$GOt)x(N)M50XeqpIeoME(Ztm$+S}W59kzNbkQ$Jr^lJ}XE6eg zqwfTQN$6`(E+C+je227(f>ZB^hW2^Yi;OF1#KNNNU|<0e5R;&3-X)A-2BFNEK*p^=YbWKZT**zX4NxVWgu)^4$fl0 z3>$W@fIcD!qF5E2r)X`|6cRfJ(jmls2Ah))=Mn@zhu0zCwPd&C1zmun32P1=r7^1~ zH$J+c)nJli6MyZb(tZJvB8{dbszRnJx~xX0qRu7|qsX$%+$_1x%5$iB$aaWwXnv?( zR5QOm#$JjO70ou{c4#mZy@te}7~X~TPMt`h#I?Y*z`4M}Oxr-(K<`BIK(NTTI2~x_ z(OA2_ab*~q+7hWTTy3ak@V&e867{0}!r>;^naVqrYdqU3zsYeO=5`j2;_Uku8ep2E zCC6f(=e*%@^)9y9zc9UO`8Y^#RbPvim&xf;%?vvA#+LPr}6fedn zuNt8mr5e`?D?6ndtJ}8v4~#EXZ?^t?-TvzH=EwH8?+>$INN@k{VctXC=kG|jJ5O%3 zAI2}Y9zc&skHVMjhqT+-bM=X3_Q;(afo}{JI`APM+X8ka@K?vFmVVV=dz|-l$5FqX z-&-m7lt}&^+1u0?zBik{FK@5zA@1$lP!ek}QlIw+>YpbV{aub&pgKdqQ#%vMClJJg)uB+Db{Ip)rjz6ujZ_cl^e5HrttLgiRxqHI|_NgxB>Q=FnHr2nFCfAeBK~` zs*!1Pr8Y>7>k0^WM9v1ip?pi(#^e>+i`r)*NcNcK&6mre&bI6J*z{Sd6HUA7R{st4 zYZu1u&wr_fA1%@Lo69ADBdE1#?!kZOf6 z)U?>+yiyZ#jVR8%S`)>#y!^uG6P@&cjas&=5y(1aOQfx_*E-fq@;6*mXvx6-e!-oT zJBv5EZyd51TaoMh`WeB-tr~dMpDM~K_bO2}PPNM#cC8dmym~HU*X4`SWss^wQ&b=Z2zrt#Kwi^koPyYL;71u-4md*7ilq+OfOO|3PtwVB50e>KfM7N2!F5jl<9 zGMzk~x^89Hx>v88{0$iJrSNv}Fu3?!-VRrXXZx6!>4zHoYkO~5%HkBxWg2C`Wh^s` zIeDE=E}VN0(`-|0yRMy=K&z9raogqE5!x}Eysf8pw_AMeP_HdFOS+B(dwfIN^V^F) zU0%VTIKNH{G+|f2H4Uc2FMZ*q)I!FgBJjG6#?nAtDhlb$!`0DJ zu!m;JgWQ7vPqMdtc}?h(|KWPp+E!Bn?bZLm4jjI?BYhRk8u&vv`QsW!6RXXsWB>Uf zStDwR|IxuQHo~gf5>}s!FjelB`#iV*(BtC>Sla zZj+UOW3AH_B+vIOLyCJLy`d{8pC(%;W^-E}vbep)^c&qvDi6dkn%!jJT1};z81|U2 zHW_2!5fL;$Ngf~@=l z`>BihxDCUn5K{9mc^DOBQ)Gz+a8|v7>Y#d<1LkLPC+Th6i0`M)u~UZVxI{UQlNC|A zaS!7ldjxYt|77W9kKln4N2wNI5V`Q`Y;LD6If9T9)hU#TO z3v<+3e@p2G%RTunP7RM=9a=kL(2XHl;i~fx{XSoH0Gp(C6Ukq8wb4S95t9V3`;(P`|0`#u?1^a8+NY(7igR=C>Qi(~v6fHWE zR+MJLKqKrOGw~pUb4-pJP`!P1-jjbk^}^bRV>~Z=cF4~>_-f!q@h8EO57*a)$z-QL zsXmPbrkibR(c)M>1G(~XdLZSL$MjubrXkBjp z;QBI>hci4ppHO)P36E7k@pWz`*U$1rXVjF!Z?QG?kB+MQdF~ubleWB^1d2~+v^?Lw z69{R!Y&g@#_dCk7GOxHhY7RK6W)+9X*J%5iKN<(QH;BQT{OoU5mR1-gLUN=1TV@#? zj(5>K3Qd8lQT^updY>-xw`QhG&ub!LI!g1W*swX6!_y_wTGwwb$XAj*J*(ACa6Tcg zl~kBBb%*bQc2%sTUOl=}D{<^nc|u$@#g(4yC(TX?f&$`(Zn>yp+nkFM$5X?i+2io= zJJ5U>|Hc!~T(Zt(qY&1*`PO>1x}*R(F>2xQQ?k&?S0bk><{plL&-VAkEXczYFCHZB z!?YPM9xQBAqZ#dXVffPA_Gj2fo^-jBwSK_VgWYUALNpr*Kd(&#y6YxJ8;tq@?jb0q zN~jN`(2Dr-_(m-CqP_k*ugh+HJZK00%`g~$UQk|dHfE^mX@K*it`FbAJ4YZ}ne)np zt74In9WrTFKd&Q&$N=4znl(TeFk*z?AAkf4zQEX(GI6<&9uR<{y69L&7$*Vcz|STC z0`whyqGfUEXZ)7;rtUy)P3T9)MGg$x2aYrcMZluh5XBw?3rM!~vq7ojLs|@fg2q>M zt4H)YQHHX>!5P!tn|YQ7`Z%*lNhPHg}jrs?7=>C0tvJ-%#Aqp-C~{dyCZo|mW(tN;cx3};s&1HK50 z)3-_sKc#Nu?jyk01pnL@Cf@lYqKMP;3@QGX(H3#FqfGzDP(K_slo9b9?*#ICUQ6TU zXAPGc5U!SU*U(1bFOgnn-SAIAm!HSUNQezOFAd|4UBIiMhp$%l5pkMzlU{Mi>j_3q{wTv$_2K0Y1D}o zlCrq0{M<0*aqmlhldGT7aR$`-z}h(=@LMIryV`*KK|W~M0b;C8hQ=nLoWSdDJ+PDq z^p|(ECiVPpI&+&|m6TK=#}&pN)S1Z3mnj*q;-JaV?t&{BYXYpe^H^I1a=H+_W_;#n z5dGi@;U&RV(+qr=_q<-78a^-nhkQQw(p%QQqFT@dL$1zt1Zd5YJSr%Ulj~m<0-55> zHIKMGny64Y(cMJf$Lj|)Yr`D(#Pk0>w)XjcJTe^oFl0PcK@ zHZZ(sXD~Sg{d?P1`@>Ho!{RP1j^)wUX&a&NYEwD{Hij3y+Ie~(m0et%B#v2H%nS@} z%d9z|Y2+jv%97)HnlkSUp9ssp z-__M7n=N$QdoR+j7kN35#Z}qb7TVheG8+;=WjYf-AGRmIAXQi)b{>dau z#Mmcu14HBeHoKXCRkelvBFf}f4fz2qO8FCWgHb{1oqO|XFF3EQ{_A$43_xk~uIa(Z zS9x(W1d5|>PxtE|8xCQP>y9ZO1$0v8zmc|uYrLMQ6L>uYpJNoxpr5Zn7C?Wki@oq-Wi+(g4iSn|pX#OpcO zP=*4Dp>h1A%l%RYguY)f+3|;VugtGCN8pu@My+x>fOBuA1c}GH1vnFx?o|1VlwEkO zz%w1J4`Vv=Brfh^hgJ0UgN-|(Z{}p}Kp7qn-p+q9i>iTOv;agoelZI#U_5FnQpx)t z3a^zdv=)X;WD%5xc*?FS9Mw5v2JMO4gD}u0Q6ykd_A#4}H9^zb`-&YZY|DlW za`}TE39-1v8fRVi$&z5fLkE5AJ2+rNQOuYXi?PlWDy`!mzt2Gt&!*R9F+ zK2DU)VJYn}{P#%|mmcLcD6Wiuchzlu>rc`(g^EbN?RuE_+zqxBGR2~p>S9=`U?O} zR!~NVjh5c6F>ehIuh3&|fl)hgMMCm19@Tu_9 zc8|C6a>6Cc=65`19bc0-2BGC1ps}E9AV?aG zj6R~V9ycSvtt5M`KeofbJHH~x3)EPcBA}nhLQv<5J?O_kqB|6|1;pdlQSSDPJ;xSL zMVwdK=t5b>Gu*}7Mne0`pWvZ@!zy{q*mtDQNQA${6Df!gKx~E({sFuS#3KwkOEAjy z5n@DB~0dG9-^oIkbXc0ia%Cj9zabH5vr1OwIP-_IQ zI5JMW8Y!IImV#&>kQOky{iaQMcaHA=FIT50*o`YHSosZ*KWw@^`vRYBa>yFYv&>VC-dBxvofparDg@d(o+_O^MO`CbWHm*le1JKAC$#LEQF7=Y-M5J~}P$bS?t6A}bREetO`=mj9<- zS5&vEoj>SiM6#IB?jzJP5_M`T1#fDl(TqAIQg8&*1F6xt*4lRMMy+f?ZD+Y0%#np} z5l-(iYj_yA7LH)X040R}x5qzrQ*|zd^SiFI2~!F|7&yRJgN(l%;h((nHnZ4!=lv2k zplALa6%cVDQu^F-xPyD6G%k7M`?(Vk)Y$sP`4n_txyAV9o|fIIiYP&PMk>cKxuXM< z-=kG0x@KM}|70SJnG?fQ(S_@YlF*rBUA0O{1F;qS)et&ize~Z)9XWfa2eL(?$BKGu zOua=>L}_v$UDLgi+f{l8(SIuENXGEfh+*Pj$ugt%AqItn4~_2R8%{?IzddY1__4pU{^2 zeF5)->frm0dgkW(c(^XB_gSl+Te-PbnAC&MtU1*y)cub(-q`e##Di&8`P@U)7hM;Z z-=otBs`!Y&^ep51=S!2a7Zl;yA;&08Edtcp7WsJ|_8#D)(>5&@ggRL21eWoWGS8D> z*{(U>jImTr5~Ixh`-fnX9OLw|vplFhmC){A>ZX1+1tU$+L;R z-lFzTkeoNWW-$2GL0AjTZ8(x=z25sg0~j2aC36+pDT*ecFOf8HAj=5V8ePxLxY;vm zHH984`YOs*ebYMnRmw(H$g%n|B@ZBX+T3W{jXr{YcY}i2W|ta?M^c^nY6Dv)`csVA zl9>a)FslA9H9sVih$opId4okL_OnU61cu?QiXKom6ltucr6*suY*`z520mpoC51y%XYE zv+8J1B)G~^8EkPYvhwKa{?6y6Gs@f`Yaf}1)S*p8AE=a(Y3WTE?6ELGL;TKO3&M5( zin)vUjstE)T{+!I+7oSN?=PH;O_+dJJ*`qmHK2Fcf*f@!PS8GoFJEGhI6H=k`DHlQ z_#;l0VKhnonSee>4rr5O4L;1IQcFJbSX_i55`FO>hMNEcScTX3Bq^H#JQMHFJPtS| zBf?cb(7;5G zzu=Bh|63|>`Y?O|?0`!f#}>=MZv^bC(iKx5zN_@olAwAGKW#t3efSSp$g+ZHUxM5~ zrAjQzK7)tIQjB=k@~HNWo=;()3U?YANxKXusWS)Esmp^gS-_5O_E*my*crO8AGi&{%P`vg3ZDCxr-;g#h zz_e=^-g#b_LXe`!gLn~`GEuDYEBs%YPP)6&v?*NH?9ep_0Oj5<52A6^-OFn?;6-J@ z@>{zde8qg65pj$lU!{Dm%;*f`su0wZ76De}l`WU=?Psi)qTo8WQeH9T!vtcKmQ)ci z!aL)Lzj5jC_T@L#mw&`D3F33(w_f?vh_<2YUgO=twPMt6)xKqC<0pzuW)?`kyUSBB zFV-*Wf*;n5x}js|UN1@4QQzjNv|iDW69zML_O&vuNK-Zoqv!y+4JQnxu^6iRNM5JF z!l)Um#pGj`9;oMnpn-xZ&2XE`?NLHq(0V6#=x$CG=KUX3i0OyNt?oLCyC*+r%;xep z@D<;Nv0DXQq2QRH!tI?0d{HeM{S==o!v(mo%RQJ&uxuMy&@al!zU0vLHodR=Le32$ zUzDAPhZ+lf50`D|kIY+cr5KUZbAn5Izr*TiOgKQ;kyu7ctSM^%O}ui5^7O?DF5U~UPi^4a8 zLoOVy4lIvkYU!2fi;6GZpPQ7_^#Hei$Y1B#(F+|2CvJ!oKvtk*g_zggMwsZx;S13@ zj>#3rv50O6&U*(*l^+ib(cR?xBQt^8xcDX`L++l9A7G+xX4)nTkTW-GnANqb$S~5! zUd>K*0^m=SDC*c_2n9*}R0l$W>$w!4e6m0=Q0tN~?{03|la=W}H1-D9P{vmg4y92T zKysNGVKj=CW1}1Hs!JGZcmju>Wm%#H`#+MOefGid0I0yzFVx(cUeHir}$cQr~Q#h4HZ8Yp^{^X z_mRnt$3B?K8DvCZV-9H_I!Yni!v~fT2+p@$764VJ4U45v})&rmA*5k z4KlfB$WB(2M-b-epIum>W;c}CKbVET4s)cn^b=!B+1Q&rWXd(rtanj&gH1(TbK#=T zn84SJ=y+!G-fP?R`>Gz07lmD&M@X@^|EVeO2FykZwfDLc@-M6M{T7zsmmJ?UZx+||-XID;S z0-~wbgz8mr)j84U&_3Pw?#?T**osuY*DTI!a7PyQ;S1C(twODJ_i9{V2cbSF;xhyw zDeTX2VPDMwzz@gamJryHOQD4wAQ-57tVrJxCUpdn#oVLQ?K;D*Iv%G3@Mnf|7(R7x ziU1hp_Mf$VmJkeA0%`9XV{WkLK|F^sU{~r|Vt~s}5m7#+!OuQC=EQPM-l3g`%EgF4 z?$_DbQ9Q2e@lnS7p*gJv$8Xrta;fwa*)3$#uG`}U%yAVuJ%T(Aj$LWGYzMGSXUFmM zDsz3c4U}Fa9xSq3U_R}8-lgj*LRA99c(8{di+A$f-F8~kGTs~(_10@Z+|9k&Z)XrH zcOBJX($WTJvD;}#4x7|=dvQ40h0+)NUp^@jf*fJbB#b8YVly8L`aFz`Vy3Y$0w_K% z{O|;LglSV<;%$<@ZFOv(EaNh385$#o3Y?uyI!<9inRo8x9*DA=Md7T8X%Uw1-pqDH zYuv+*f!9Iy(^;#0;wz(RHGB1==~*L1cbI}LW4v}Yu)EtjMc{V)d27|IIvbAn)tT3~ zgQOO$wG*c(_oV%?QCo*yMsK+%&KGaj%@NZR$25~`9dCFvZw_lf_NK|__zri+L~qr> z&0m_8zS|eSf4Hd#9Gh?xd8q~@9W$J6PUO%}vudQ)@A%zm?00|sD&zWX$*xZFb^{Tv zjjs~eW?R;bA>#W)-zb}M_q#}N?@}JszBPg{bH<`=r-s!lYphcD3jtu)vzO>ZiyA<; z$8fM{m=Q^+*)e2;W-{w}ssXjGrI^Y2Mhbz= zRBo!IQ~uu()yc-2D{hCHPg*EQLL57UZ@EJ3BE44{$YI zt=qAa1*#_qM!nVYX%Jm$*H>^UClShEI+rrn_x8XAPqxZ@&XEmjIzjOKV=AK*@%`Mn;y!Bm6fRc( z%?oV>*5Va;jdmr%Ab^}uDwGqRtep0n-l-EJGDZQ9`g-{I_6C;ZBm_H#!N(D{1N{`n z-V3x_0bJ+s72J(>7HZibdO`UsIFSz}OVC{|{aK1j^wJk@jO42AD39LTcmQm+5h3gM!*=XJIQ z`2p>^IQXVNO3O?)OL_mFuK&J0Uep}0OUM6X!tsBZ(4~64At-^%T6}0!N?9O)KQ$S>A$JT` zN`zu5&&#!#wP98ortqZCnjp^vkvxVu=)geq>Eq9sJ3}oR?yVBRzM}V4BF1vo6L@AZ z%RnHEH6FFsGfRgu%^}GUorlwQiuEhFXc@6y%(>ii15mSVQT$be1KF+USuG@xLFbk1 zDwdISoO(x`?Kg|s`Ln`R#%n8Fd;Tc(V{P#JI@CQefZRb}?$&oQ_YrR~%hcq_-l(=_ zcT{nt6JH!(s)MF}{yg4|R)_ik33I@ed!j6?!wTUreigo_L#Km{zX6<+Z1k z#>$hTZ|;H$ii3d*omNPy&vuq?0!!AvK~OuQmW~ge0fm$5xxkAd^PJfJNLCZt3D2Q- z{XL09H<{K^uUMDLqn^ylFd?$etYSr-g)PLpz_+A}AJn&!b5l6VHLh1tDl1I+5=TaDI& zDQCK2p$Dhsw7uC%;o~wGYZ5oV0@GU3eR!AJ?RI+gsAH1ve~p1TBw2j;*W@pnYOnL2-de*iv|lWTBMj5J)9sM%LCgY9^+`|7)bpmA`>ir zjvo30c2*AGoq6=aI`>A8OoeEQuo+-uBNqn7!&5h!0*+coHLT{42tSCda5|cH{^gc8 zW+-NJM~k|cczd)_9o&j&m>jgs@)gnqWA0ZqTvfSS4a8n4-9`hCi7C?PKBNyc6eyMX zLK@I-sx8;kyBy!&Xq>ax4C`X9<0|Os-+hBViINB zpF3WckVYr2L0v}Bl}WhM*j0=Qj3~1lICUX%S%g`|K___GN_EmRiYeT6Oj`h+d&Wc* z*4N!Mt~huBxiDvNW^v;vBK7KAmV>P9sE%q!szh=sLm5lGyy-e7iMXPD*GtAjy05M3 z23c;@zSBBdU{0&!lKtUY2>pN0LNjHwQS_c7=^#jN^$|h+k$vut?+Nj1sJGas!P^^( zyGbq_^e{RT-h63n@SrM{H0l^ zaRuc$PzrLgeGR-3m$wtPJyGhxba*`%BKQGG=hLDT_)hwty+=`x*ULTPSnO>h1Qabxztmy ze%0RNScMS&v%gk;fEL|4ylxeOcyKou#^pu=i4m~_Zm+{hx(hvrZFoEq?0V%`!2zqc$#h~24&>+pi_g4V0U0FW5MT~FVq`Vp zsQb2PCL~x-<)fM`NNhEh@B(DL(3TS?mK@;H41;!vs~3M=7L6VVd`%Qq9Z*(l{JW|#jj=uCX#Fu#`EYbB=XaF5oJq1NatN}i51SjZ zXn!Jx6GjcND02tcU(%DB(@}?ePkKXu4Kp~x9*pV$2FNdLE~hWT(B}d(;G{1#-1?x4 zr**=Tc0CJK9lJ&*T1q;%G;}LkzWOzT*Lpu04F!&d9CcV_h&y&(ot(#-=vh!EKPWT%Q&9sO&)iMT7Qad5=!9~; z9mX!vR*dKSO2#_S=F$lD1J8P$S$g2fn%kca83kTTImq^m(JqVsl=!^sG_MW9ck(Z9 zg@>ac9Jj<*Da15(Rl4XF2$_7ymwPCV-+nz)?mh#mj2vIjw`M{;M>ZPA(H~gyF*Ozo z;raY?KCj(WgF3zKJ=>SAloY5P}1&+RuKrq< zA-fqJrl}U(YPs+#i*W+bt5lYzNrvbEULOT+m!p9r>T$MAQsy)ZKCWnr@H`O&tnM?u zJ8~dasBWka5ym^;{g^ycB|aVu=>~wE2!-a5tceybnBP{;a7i%7z`i(oP)UOWwNWNY3bII#L;O5# z_stU>EP28bHUx?eO1}ZT>kuDkSdH0SPT!@_DNHq$QMy2$FafcskoY{IxrZR#OTYXY z#h`9c>jo3Suo> z-?cH~VfOzMW;&V0@j=!+(Ry{lngpRk>y0gV4m!dc=t#6=&1EA?gf}8g-V2T-fF`3O zL2t3)mu$eFWy3NiGHodu(<_5t#UObW8=Mf0q<)@$|DEbxZ^r2`3Ma}{!2>vpnJ2^` z(Me^{1CD-De56<^ddQkt1cO&}DeC;MouOyOMLj&piHW{DJr#_@AO*s=_f)gp zLofLt!4CSn^!|MkSKGYsz5;O&2*V-fkh=BndGAAS=(InuSO)h>umJ_!lxy|N5>BYv zENxco#ijSRJU58XCg?sFN1%dkNJtsh<)f1$D%FTd;gf?EvG|i5l0A89s1iJ*`a-gL;q1lg%SyxksUbP4guPvHiv0WB z=SNOJH<)Mar7!#S+<`~)Ha6<}S31p{irsKtYcKth@lkVp_vobSKVyvCOu^jvfWT*p z=B*F>8I1U*Ct3=X5|S}S7I71qCQ)b*eb8BSBNr!Y+Gv{X2eul)YnmC6Zoz7k7Fs^+ zp(UfJ)}eKVEzBnfz|1l1K#ynU6qak&0|dlR5NI zz!JU(2{KQpu;3`R0dpLpShWt8EfcRl*{nj_ZTA~KDs@-Wr&X)yFvEHXwoxSDZoKG< z969e;O@UfdaB2dVM7A3TFnM$D+Oy5-v&|6`Iexq%N6tG|Q=rxqoSMJ^Od{tUt0_=x z3QkSn5||rD3o{vif;#*M>y{6NX4qz~-SB5$#6y^mJBZW_3F5izIgW_VR9m1~9G@hT z#UOvJ)BVPK!i`Aqlh|YIE89_6fMrB`6v4zTij7!C-c$Gtbwp|sE#i*st98fUh4sWZ zc&AwttsAW!M}!-_E8K_#=At9?ls}6WUp`QKi1T54x;^}ab?^_=mHt7bW@8s|QJ!{A zTcBAq3+6d)l;%c+8@+ptXq~A9KN%}T4_UiK;W})$`WgSlI`|3e$Zr%EKp7DasMI9h zi86WOHKMsuPtC1%+;!0%-f7n7PvSdaO(ZePctCuR?I`$QGHG?BPzcFnD8*`A4^weH zc}39&l8MogVj(7z_Y|sOJ(13cKSi><Ycnbbut~)}^A-8a1zyESngd;?lN;(!X_2fzdRLXZN!Kv6-TBo)6; z35kwWCagVswm>}sNn|`-Ifd+BGH3f##d4&dO`2+xc6HQirTXOd{rlD6yb}x0DYu{0 z!LZ#bIPV<*0XRn)s!fx_rX9UntX?g?c(h)*cx*;swi=inF(X`&-iWubQ;s;47QJ}N zyqN}chJ@rJi%%C`Fg)0F zx{D5braX6qzRKI%htAil*X^!WI(;P583Vq~l}!`=R-pauhNoN9g43A?7wxUybKrPkP04$| z8jnM|D>z18lejo(`l6)mvr6V{9=CH^RbfM3Re9CP@;yrrr0>sYoPR1ICqySf8i*`; zEnCa;_Q;D_IokvM<-6Ap*B@6W9w4#FQA?&M9y709->6>SSby!Ya`O1HX%*_lJ>=s2 z(4#&Ib{Otvx=b})cGvnJMS?W3%oroDM?=_^Tt7I_xaZUcF#Zv=T(O10z@I*{vzFR;iLHlo^M}`G*Nw z(N*!}GEGKDa*XZ;M*`J{|@YBy2BR5lU_%?JY!%rXKZ@HN^3F^zy;lH7BGi?%dDH|>l#C~}dchVC5*)x$y zz?@3MMXX4aiyO%*9*1iPnny4&r^)E-&-Q{d0rQCr&FM*4LnQb~WEm&PxEMWg2u9CV zF?u$1MGweP;QN10-+rtX6|XJKRA$qh+}zcx)j(gzS%a1S-uoM3)wRF;n76K=3c~e8 z>wd{AQbYqS`X#SuoeI$haSyFZ+psw*9n0QRQrRe!vuXW?95vDm=WN`$cbk5Jj@Y_; z`_BCQTA?q0IQLwA{&0@}roLnS)(xB2dgbuYcfW;`R%!nWMUs^E(z_9C$tsB@vr!7Y zI7VU@vD}mXKSSz+*%I~HX`iyn zR;lC|=RlV8k`@pe2~+!9UbGGDkvIaeLl zSJwEy&P*s4K$s_76+ROlxf;8MyLP+E+%?^a?oRG*?q%+4?pyBf+%MfcibO4lS`@WD z>TJ}dsB2NhQNKt16~&PU$tV`pN6k=6)DI0p!_agz8!bh9&;fK3T|}km7xV%*zzMh+ z?t%N_L3kJ*gD2wk_$s#WKc4!Y!Jg5c9M5LYUe6)VB~P*Ef#;FuiKoKzj~96*Z;ZFC zx371Yca(Rbcd2)UH^+O#d&^tuedI0oQW1%gD2sK)Mq*2`oj5?8ET)V95|@dq#9VQM zxJBF{9u-fBXT*!*HSv~sSNuf$QhX?uiO)q_d@XWGl6+FElpwX1x+!G`{Kfs$UQzT#J4J~PsJZ-lOp|DI2$oH z+p92dc4GIsD)XdR)O$pyRgO~Op{>67y$_37sBhkY_EN+XWvnjJ^HP(YJROxgYPwUanr*)#R z+(;MZ4tGI)F|8fhxF~g6p9xb28oJzvpVvJNee${%{UUq=$%?uAFgKRh5`~>_t=+Ls zo2JQ_df0hXh8dyP{(IulcZXlpva{{}hRsqeYvHUYosG@=_sIsLtj5$sYZatisO{Lu zrHfZD*f6hF=amooK2v}G_Q8{b`s0Hmjkf9+OjjnZosl*|9XbEPt=;;q-B-@vS05Cm zPh6|Z%43XO{?7Ph<|RsL_DDM#gWTKS@d$1q5rw)RKc&?Qby}^^|7(4fxw~{^)fVck z+QLxMYjw}3N=n*?e;v{PI`ZL%C2Glq(Q!-lxTT{L?nd>H7_6UwY|Uy0Dl#*ogJ z{|sh=#Ee^04)!ow#fYoxC>Pa_>`PQzSyWghysb>_n;I zJ5kC=spR?KOl`DI=Z}%td_4Yc6ERKtkyU3c=Ib)hS3<*34b?MBL46y<9IYupZO5n+>gxB2(3(A|EAF=byw_nmGst9Frm|& z!9Ieu``_RYy)a?!4H}_~-(Ag+T45H7#IlazSk$4l_9kr8`QG><-@_1FVcidh%>50VjGhNuo4pQHT|)$sjm zY5p8tj<{)u@GIp^Vc`b?NCa~;(<84emwq#%PA5lY(I7lIUxI8`&f=j8cGUox?r zlpOz_c`MXKMCpV(v4!YUOz3t@YrcMV0{X(Lg{|MPN-B9bR3Z1(6Gz=OuTGW7-wMW1 zn-IP?;Qee8ANiWy4S$VBVxsK(8WGom)lQ9>g+G|!LCird&L0o@1ut)kTIfjs>_6j$ z%(ql4q&`HH)|k~}eV?$ts2q2ty%1q)8$VOCb%NLQLxc@rGEJa^&g*p0Ioh2j(7`Gl zKr68XHYh_U?_@;!YVwIs?&u`EOG(Yj&Rkz-;E!8yRz3g0iL*QPvpbi(pQ#c|qn)3f zD<0P2FBYY~I^ln}t}1zq=@zbZeXYH8uP-y>P|7{wEMwfFZQNr!V zXieXnunDsXJ}8k?X$>)5{7*~hgo^}k%H5o^DRw`qBlN&>(Ya_FU)-LKAeuUvTF|1T#*Wz1YKSCE{~#kzRc(sg#d-B35q&A9B`Ia3#82us`=m*;l6 z1Mavx?ERcKTmfEFc4huc^()H z#d4J@?^lnm{p!7tn$=gCp=#fhU5KWe;Bs;?HB1e)*B>?K&82SPxDEAGlgN zvHZ%mi`s?22O|M^klWnh4iE8iZgYcIaI>*p)OdUmceurEZo-)kfBESSH^Ij(9^?TY z;Eda_3Bl}A7|hpgp2tldV%iww=No3g;7Q!!j2oP>2S2_GnS zuy}|Y%@7ptCYzsfrJ2+N|?Bdofe#OkS#;d z4ROvuu*zO82+t9liH?Lm zYh=p<;A?J}GX?3F0YY0tB>zKs9yfS^2YCfwB`YFrDC{FV5I=Aqf++=rg=W@cTt`l9 z5~G#!q2h8x7a0)+Uz#MfK+uHYB=iiJi&*h&ImiE-`Ac5$A=a_=_Lnb33S!3sSKS&tTe^(IIXe(fL$r=9_qk2igQp1Gf<@m9+)BLE!~wI|O8+LE)}U zzRSTRD#!gz5iZ`qcYz#E5AlK+(B4J>qzqpgFx)PP4LdcGhvGvghNZ~Ne71QOHOP9U z5_84723Sd#V?4z1#N>W&FqKJo%9>bGWS=EPgZgcf+#)~9OA~zC7d3Q;D)n3fR=R+$ z89tQ-bXKnxsY;#3w|2YnF-rQm6p3NmfO$utRou;eQu*}Xy4y#C4?DU=n(?)ERo+hw^;k%0G)}}XZe{6A6WP!%)9Z0zL zZ&~y0IfXaIgfolyZES9{hR-Wk{R47bYx-5QB#2JBc8Ykm#R&ozhFWBn5sDH*Cmzf< zz^@Cl3(fZra?7@<4I)~UFJ}qMVX;!~vq8UJ+4sZ_ha9Yud31SafD@2k*~#Mw9FiLN zkG2ab0&r^_bAac~A<)JUz^EF524X=pf{O;4ra2F z2o2U`YShglB3T&k{ zg^wgz5?Q`f?p}iL1~w)^C=Z>lKslj6@NSmq4$f9YJwejK_3vYI90(Q z-dQ>F40!lprv{DjotdQoE0Co&Cu%csO)U5%l4I6BtMu*PWBXm`*beToCd;K0x??E1 zSOF#~;%X~XynbDdnqiFh_#QjUOCyET7Xj$TynBq7?@Q`@DU-YA5tdUT`yW8ke&jHk ziFjKyvHiX{2tLz0jnw}M$`ho?JwXtin0I(}`NjY_RQ8RJHlmo=Ey+W4-Z-YR4wEdU zXvtdbU=TluJK(DIr@`NpI3jU-ms3Hvq|(La`;w57Mv}l^uy99Xxv_nX$pikRGFs<5 zGi7fqh2gUdiP5|?y`6`&!)ya5L1;;~`O+hM)tHF7ZZYz0b$bMQ=d(2sD@8Co?80pg%p)W}7 z3isz>B9eIt0DH@5h;O60?vq25mtm4d5zjj~6fK~v4aPr=fjbu8C7c)dxaYwR82cmY zb4?8oM`2OYxYlm0RZp}JksK3~aNdwCAv!WkcpBbi=%a%!-w4GzBp3+}ctXgY3_#Q3 zGX&CfFuNoa9H*8E*B1KK)@yAm$Kb+3cz1BhiG`Xw^KH94$h_}nh(ES+v_dph+O0lJ zpH}VfwS;L2*Fa1Up@mH-twMz?4*B{DvcM$WTOQmQ&78M2$)mio5(1n!V@ldJ$j=d6 z$~LNk7~{E%hG*mU8P^~SZg)W+YS%E&_fIpnh4(DUPJ+i4wLXN|h{f@T0zNW8Mco+! zSJm7`ogaTCKCI0#riS}aGy9M{Kpj@an4}Nz99b5XENtM^z!w-0)!2jiE-ip06m`Q! zg_}xaDC%=d7hNG&X?BbmzRUx~&{Px2LWJ511w z4r=0a@cnwDH7L6arUd2GnFus))_#YA-{hPSyDFo{#kNy2WzwBdR9PD&z%a1f#U!37O4zNfgG-AxUPpQm*%8h8-5!6lDS*7=L=^QS#1)-)ah<^=fcN&5sb+2mcjn*cz_Yap>D!JbT%SR77PkCs>IOkff zuSMZayf-vyig+lWHo-RbO7zN`N~pP_TQXc1+q=;V5K=+KkF@O{0wMb6)z<_nLp*ea zWJrf~a26iIKSI3FUq}_^3faOcVJjeEI?W=?n1L{p+d>+*h0@D$1>qa;fhbJ-W49yB zI%qa$LhAN@>6{5)QB*qbjD_htW(Vn{8Qc-R1qsAsZ8b{;>AhM8w>OMpSLOZ$dq0{% zcEk>|xkHgd6RaJPG@Z~AX7HE7OwLZR39IQSN9@EddlC;#hqZ)MZVKrXeKmI(RpFb{ zZ;{)s`XGU~_J-npnZCAzJi<(FRUg=6sk{*j^@P{V0aq1vm;3y9dc}Ki2cn)1NCTgc zUeW#zxX84|+(N^_4buUEsL@J()F6P#+V5n<-b#55BXpUINT<}-ZkP!okkwO-ou{BD zHU-{e%G7o$L7-2zvk~gb0)>ih-&si&TCJLGm%if zRYLfwn6PUFk!BhEZ6h_KG%DTCN zsBAJO-3RSHqm1wQRrm1s_k75n?;Zu`OIN(pJ<3yw>ZyrZ=^k~s?9f%c=VTzc%3xGb z6-*6g@3&B0wVaZ*lx@~diuTiTSL%A*I?_?yqtm*GYHzGEtsVX+)_3pGuYAmp9A^Pr z*~V^86|P*EsBxc+0r9GKJ{Hj=<6f?#Huj6j_*xZ&Kzh~ z(ppBDRh!DHFsCkN&bBJny3ky{j%&@Xo7$={H*eJ3Y3}du%C)`Tymhl$73R_YPWN}Q zuY1<;LmO*eX+E)8tqSwGzFsN3wV3&VRk7C3%$hCwqxrk}uVrj%tHSba#Qe+(Z@iUZ zWv#Wqs#tGjR)ck_u$uLk)vL|#9UzTZD5FDnQWH|bGta8G;1J>^<)^D&CF*$8d3b7^Qk}X^ z$N-W3(5JJQ57a21`5Lb(sxQxFI*>|jS4ErCZ#BY&5A0Z-KtU*d-b)xW}m** zx9mrx>N&lj7xj`}(X0ANKkI+h8Zr#SG<=3*1TgTcTGgPVI)-#YXEAkN7jbl_?j)#t zbPpjtpl1l{U42EiYE(mA!!kV7!yuIfYzDS+2N@jUYg(v4g)CFK>aarf)sj_et@dnH zM|EVEI;j)8)m2^DqaGT_UJX`)t2IPJxmLq9f*UkSqqs@qG>)73U04$}@kwa1W+C0A zn{W^^ilzCQ@(thdJwNaxze+M>%9c-l1r$Z~s6rtTV~q=spvMr(}5>IV#W-B685 zWf4u(tdXA6bNKawUL?@2Izk9HU9y z2R~jyQCBs<524=98z074lhgil{ zT*WG`<2qJzGu5o&C@0v=DK4^|%UotZ_wpzQc$~+%mM3|I>v)Z~IKn%8!buET6Ci*| zkRT#N8V2b^aEOwJNj~{l6i|ReA;owop#+;U8e-FkMx@Y~CU|K|OMJAV12!G$fkRJv zV$zFV_~}h=!t|jJ4t*IwfPo|lGK3+77|L)$jG&4XCNPy$rZJ5)rZby#<}e?ZZ}}EA zqu%5fe!<%m*}r+gUjE`AZ2sk69RA}!Z2sqe{M1s5p}#w|56O~6nmqDg0|Xn}*$ zNd*+Zb7`McNFiD)tT4&8IL>MesGB#)nkC_s}Tt`R%5!TiJFp7Gc_Zw=4wGg zE!C2^TB#Lsp_E4B-BM+h}y{i{t0zcH@c|1x|2{3^&nq8 z)swh-sTUdQtv*z$ulf>GfAyzQ12llH8l*v#YOn^AuY?lBl~j^m8lp;yG*m;W)G!TW zu!d^{360bkiZoVZiE5n2QK>3bVQZo$(p-}?iAI{N$@I|_O<|CxYAXFSP16{t>6%V& z&Cm>*YNlpVSF<#WhN@OIVI9|TI_ZQ?lBF{`Lw}vsS+aCa=g83oT_jJJbcs&7Q+Lu` z_v&6MbieMWn;y_ZxO!NRk*&w|I9Yl^Pm`->^bA>gPR~)U7xV&k^rBv*o?g;R)YmI| zg&}%XuhLBK+Pdjlwoxcs-9wZ5K|fIG8-^uQ|LcEJRI6HwjenZB+SqV~aT8lgNi&u> zY){7`-6%GSY4CIS@LW9Dc<~WzAJR|y2}eKcXZ*)YM-q^6*eXpUU<9z#88w8I1{nz> z5;_#Yg#-!-5g3@5Sa=9ylP(_|@R1@P5adMxErPEkq#UtB+$Vc+~5Ct5hQ}z(1K=ESZ$QGZxvOB090WLtz>bC7EbxOik40xl*&xgsQpWZ_?~6mf{* z5F>^|3gQ$}Mko!xx}z87;7HaBw%9&Y6}Y;NavyxhSZ*iCjmcX^d} zawi6N;b7w@;H*wh5adaoB*arZO|aqEgh^2tC@yu#rmm0wqKcy_I_JMk+@tr*5ntFxry z&S*>_859vAS6P}-xmr}N2Xa)d1ZB&fER_o$zgqbnJot&yhm$G9LLqhvvE#;r9UqE9 z5-G$&AujC7(TTMs8w;DqArBXJqN}-dH&`hoSBaWYT1-{Q*APQ71!Pl74^k;11))d| zd#F0-XmAoqk-S6}-Ej~hhm%lG_n>^=pg6-&cU3FRHq_mx5Kac^#L|mQ1kD8%R-?kI z!-f@sSftk_l|q;wIr%`FGLp-h6mCii7d9>wE?f$;8nh-?(qu9?aN@;@8z)}8c)U-M z(^x#osR2cR#=Dig4=C82eWyR*HWL#+F1hR?&`b%03-lvE8;rAn2D*x!K1ioiek4;tcLm56vgxM8)-Dz@~n!r#aq*$!|r0y?i) zWC>sO!UQIQQOl0Zw^xfDJ9gQ{MYs>fa@MajDG^c{3rZ6aA%*0V4MZ-M~RNVbFxYHuqrDlJem?lc$4&G zCn=VswJe_oXxyX`-!rqPr)L^z#1Kmw{+_{}-ku(ENmI7i6_U9^n8rs>a!DgzdGgPP zh&-K{u67n%9rW=tIDB9Okq-bQ*gB%KoBb9Wb$x!s9QcRPMUFn)t zXd;SadeNOexQHahCCCyQ^LkwD-=bLgVU<^Svdauf!m=?F81&MTTU1_7vPlT5Y_gF< zFu3HAiky-_OLVigrpwt-MYc~alT}10Zb)u@a6;v^v{^Pr5p*;x(zuk);z2qD2}rRD z6|ORzK3XQ0bja$&i-y91P81{xD<16FaN$<8*cDkVHib!8zM98)LXmckgdm&&2B0&U zDUgG)C072j0UXF&G=%H`EZD49GeAy7lW8YAvLm4neLmLtKxfE9A0RW;ik%wx+j7M-S3TC5JS{BZFAk zq^zKb6~qMH)O{ia2OfeX5==Y+%>*qE6mTmH2l8uM;UJI=0dY(x!$zON58zoEZKGp0 zEH*)@-s?dYLVVkZ3~WXS9<;nu2{;Lpqg73AUDjsclAU{nGo5$H-npcmo+#AVDUzMi z);-211F%Yk(tl%hWq{>JG*gTbOJPpCh5EDlbEGLtVT^X$AcB_L%KzKpy+;lzrDRZfz#`C}xs1=$F@$d1DMu-CTpQ#GItG-eaLe($;iP#jKLICp&84u5qq`_-S)Z8XBR{+ ziZKXdF%{Kl!3u1`-p_wh&O)x`IleDk-i8TB4Uob=`uTKHkcYu2!!*>O6)UkB`!=P0 z2=wWtq9;l)1miFrwP?dCY{7n#{&~=+mxg?lVkpWn19ez{)!2#-vVP3d?S^#pLT?Pi zc+A8+EW{dY!!;!9r$i5%fdcfw@af)Wp&pCSj_tU1lI;VdPcIW8^u-8Fz-%;NG1g)S zt|NtgcJ%3Gp%DEr5)&~8^RWc$uoKtQn_)*Zl8}ue^v5Vn!dx_BDb`~bZorL|l}(FP z$CaD8n}>Odmw1y8`H~;o|6X0)+@}7lX=<3KIxn0rbj_{_HX`uh#~`&hyQZbJS<9!G zehgt0!@@aCvqeSa9GW zwm7k=wYo_Ul1Uzg^kEPqipyG-G%wUAGM%~9(ZoWQ)6OP#6z^+YSkt0k&#m0egFMbN zyu?A?<>TTn+gg@1>)-P$NBNUZ&KJ67w+J>OihYYJn-*LA7^IR-KE?E72%{)3p1i1` zrOq;wD(Y!rF)LZe7Ix85eB+`;c|9$+b1x5bfaiFHH+i2=`MUVWMN8TiSq^cWzd6mt z!qwTW8g^Vn7bh%kS<-9`kWLN-lrn%}jG=<5njNS82Xuc|w}ihrbEE!P%7{M(g-E#I zCL36pt`l~`u%V-&uwX5%v$USldP^Hq+VIlGmex@^pZvC*4MTZvYqa%f!*Z;{HtfTV zxDyZJ0A9es@MGgL`~Qj4xIA2KZ*DE~WtGauzd2CwHojfm28V9Juffm3kHPnqy}{SP zm%;hqY;Zd04E|02K2?84v9UAZDW{}{k`5Yk<2G`SUiuM8!_j1IO z5VZt-?QztYy7@DPUTJF6`pekhKhU&FXP7XPQY<2=Xh3(L&cNRn!PxU(+9vb zo4?`hpuCHGP!1yfGK{>0L{MHcPhNJH$C2)~tMbB24yD;yyHFmTY?tYOcS4hqrjF5_~p;7YFIYOY~B*YazA!*BT= zzvmD95tOCJOFln7{4pr(#T_^S!5x=WrmxrQaKMLzPloI0W_CS}-?F=Lq;odpBm~zZ z2R-WD5Xpo#l6P^Dgr|0XE+{{(bxsW$= zAGdHdbS~m8+|R9C1D%U`D?7N2?a;Y|xA7Wo=UR|eAge*vfUE^s2XY?BdVE&BM$R9S zy`2zFMmTQZ#6-Let{?s*_5cyT#GA(|$73?y#QXJLbm9`#V51E+`jNjZm%WA?lfJ*n zj~OSle^^k@U3L3P_YPiT_Z`=9`&Npg;m7W!Tzk;h{1Nu?&L+}VkuD?l7NgE_&M1y1 z4({YG?&coupV|1=65C&cvs$q{=}DuflwkmjE20du706RXAGa=HxiA2 zk!s`_rN$8BQRCI5=%heWPSWh8n*tKh1Caqwz#m8qWCcb9<_2n0KD;8PS?r6=Ln-dU zBX}Io;APqw%|xa$lQ}G82?zdP*gW0YoX-|^NUXdo-zzAKvMY~DE$78ii_qe=pw?R( zqK(&PYx@${C7w3CMy!!w7?<893(QE`*VFB43cNy?kL6_j_KaX!uAce-qyL}(|E|j{ zUB8|~*V!Y|!w0U9XUpL?5AQfU{c!SO-=TAden0f{q2aW$-~RpW=x@I~iQm5d?c?7L z{&xFs>wuqo=_kM_{+i)&7IGLzu#A)WuR!eLlW2*RL@^{&ilv`Slsc)W&m>!9m+X-n zXa_%j9$=B zs?|l^r~6f{cXdJ!>8vj6xGw1veW;IhE}PnmAfaKKdoQ4bG8)mCrnLJ1^m(y;=*vKc zFoFq8WE#_%qkD8j_ZmUnX%r_C?bfi7t?XkzhX*J=u$#G+BOK*4cX1E*@*t1%46pJU zZ}Scx>Mq09-TG3e^_*VO^Lo|r8ZruvH0y)0n>fXJ&Ty8?oYMzf;su`NIiBZT?&f{GEW>0GK9&<^Eop2Zg>_`G znFw3RWe?fxB9Fbq*-jw`DCQ7F9Hf*hXuu6LX52;#?w~oh(~@f1aEx}G zpe@H~&q=y*k*-{zCwJ11`xw9j^yhvC@em0fVK5IfjHejNlT`8q<9LZGUSTXRGM<;2 z%p1()J!UbNkC@LV%;RH{JW3y$f)B)yMCm30Nt0Abm!L$7OFR-SG2)X5aY~f9#f#I7 z#lLhhk{){LqmvPg!bJ+h@F)Jl-}nce_>sfl*+KK8QBk9XI>pRls}A;GYSE0z%gboD z9$k`&_u>+kvJZPdxq%$S0UXXT9Lsc;G00&Y+EGh6YViJz8OofPq9@*!U08J^<>USSt6^DNKvBLCsfJjx@^bH*5aVbJh7k$uiV zsgsw3@zs6r7u-&8^%K*j#i@fHu7+C59`5_z2Td2CWnBHlEYafBNxVh+HI_`C#VlEB z{+j*5Rh#*>G$AN|AS6fq5nY&w;WP?nGW9Q|4p$XEoWj6~{qnSVAxOofz#aI-dA-+mWv5k2GorqH9@M}%ht=l;W#_?{V=3{Yb zH!tjtRQA3{USW6h1b=#drv+P4H2Aq3p-m?_$R5ls-bQ|lLM0f*!n|spwEM)ZSj(Gc z*XBqWN|k5LqS^Xa8;#()*KG5hpE5sQmrkN8%#%`tXp%wMou4<4tPAEQ$+aY7*I~0~ ztrWwud#F@o+wGVOFjt;JP(4T2*I~bI+`YewAFbw z;jYO=y&h$}6>eNWH%>H9v&y{8DzlSQ%qyI1{?BxHST6r&O+pNB#V}kz1t%hnmB?Wg zQaBm8oP?fC$8eS-pOY|#x1tIc(aMP!%u0-A6$WxL#&8nKn1LFWql}YKkDtw%Dh(AX zgbH;Ba#S)Z)NbUc4aij!F`V16P(0X<6Bx^E490Q9pwqk{RfxfH#NfEuB~uWO!-8>}!V3KUdB(Y%f~4H<3(fmL>cAr&Zzhqrr;R2rPhz;Hfq z9*}jQB$!8J6G~;ac|istRo=xQErSnSKF2UwixgFW5z>xP@;QdeS{SMTWzvownhRyp zgbYc*5EYN0+<;seg7Ko@Q$d8}IQ$X|xB)GHEgZ`tJcQ~8@1Y71QkM|qSquWqC~>d3 zTe>#Sa`Wa2a28_c8sAjoz z%0?NUH$TV!n8pDplPb#neSg4-yU#{r?aK3D+duD8E$rARX{*}3(PRfJDD=aPD`-f^ zw=3vaj;nb~yA^Cm;L>~;D}*DF9X%bpxZs#%XEzdf0E5wrHmtxxG@N;~TMPpa!9YIp z-q&kqpb9HspaPZmSbytgG+-eNOhf}((1=znMGg!ML=&1|XlPO0=xebEwOELys6`EO zFdPkN!a}qvW+SoquJVi;P>l-IVjh;b{VK5#J)QE>pSL$AVKkkfsb%RX$7}LktGc=ti_yaO67~I&bAJ|R@Bqh}&OZPE literal 0 HcmV?d00001 diff --git a/apps/smp-server/static/media/apk_icon.png b/apps/smp-server/static/media/apk_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..60ff342d36a27c8fc41664391334a980c4ebfb89 GIT binary patch literal 18130 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 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/smp-server/static/media/contact.js b/apps/smp-server/static/media/contact.js new file mode 100644 index 000000000..b1a99b74f --- /dev/null +++ b/apps/smp-server/static/media/contact.js @@ -0,0 +1,66 @@ +(function () { + + let complete = false + run() + window.onload = run + + async function run() { + const connURIel = document.getElementById("conn_req_uri_text"); + const mobileConnURIanchor = document.getElementById("mobile_conn_req_uri"); + const connQRCodes = document.getElementsByClassName("conn_req_uri_qrcode"); + console.log(connQRCodes); + if (complete || !connURIel || !mobileConnURIanchor || connQRCodes < 2) return + complete = true + let connURI = document.location.toString() + const parsedURI = new URL(connURI) + const path = parsedURI.pathname.split("/") + const len = path.length + const action = path[len - (path[len - 1] == "" ? 2 : 1)] + parsedURI.protocol = "https" + parsedURI.pathname = "/" + action + connURI = parsedURI.toString() + console.log("connection URI: ", connURI) + mobileConnURIanchor.href = "simplex:" + parsedURI.pathname + parsedURI.hash + connURIel.innerText = "/c " + connURI + for (const connQRCode of connQRCodes) { + try { + await QRCode.toCanvas(connQRCode, connURI, { + errorCorrectionLevel: "M", + color: {dark: "#062D56"} + }); + connQRCode.style.width = "320px"; + connQRCode.style.height = "320px"; + } catch (err) { + console.error(err); + } + } + + function contentCopyWithTooltip(parent) { + const content = parent.querySelector(".content"); + const tooltip = parent.querySelector(".tooltiptext"); + console.log(parent.querySelector(".content_copy"), 111) + console.log(parent) + const copyButton = parent.querySelector(".content_copy"); + copyButton.addEventListener("click", copyAddress) + copyButton.addEventListener("mouseout", resetTooltip) + + function copyAddress() { + navigator.clipboard.writeText(content.innerText || content.value); + tooltip.innerHTML = "Copied!"; + } + + function resetTooltip() { + tooltip.innerHTML = "Copy to clipboard"; + } + } + + function copyAddress() { + navigator.clipboard.writeText(connURI); + tooltipEl.innerHTML = "Copied!"; + } + + function resetTooltip() { + tooltipEl.innerHTML = "Copy to clipboard"; + } + } +})(); diff --git a/apps/smp-server/static/media/contact_page_mobile.png b/apps/smp-server/static/media/contact_page_mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..7d829965a8d29b518f4b25f50d6ed7c75fe92d6a GIT binary patch literal 295874 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%0AsdNppocv=evgT6SJ(a@6xWt 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

}%MZpNZp?kkhk6hNC4}TD%{! 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 + + + + + + + + + + + + GET IT ON + F-Droid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/smp-server/static/media/favicon.ico b/apps/smp-server/static/media/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c45f3acf04d103cbca57e3d1e6c12060068bb2dc GIT binary patch literal 1150 zcmb7@u}T9$5QgVauuUaeX*yp*tbG$-LPMUyU=gjflk+SBRtX`HPOE@msv~V`1!4SV zm${sKQDefN-JO~5-`TyKNXEC-5`GWmY+GbkM2-+4A~DV<0fUS=0*onEy&X>8rv zSfnWTJnRF~e|Y5D$D^O+dXp7MZFIj)#=zdf-33*>tYc4iw$9Tyu|n@*qZ+(Ew!`#8ma_ z`9Jw7Jxt*z@DurIZ#zeI7WdYfi+5SRIcLef=CNSDV7^>E|9WjYr>=uwSM|n* y*s}Rvro64qBaIVlywjmpMLw=Y-Va1RPojGJB3*8FJdUz?Z;#w@%v-H7sv=*^6aQZT literal 0 HcmV?d00001 diff --git a/apps/smp-server/static/media/google_play.svg b/apps/smp-server/static/media/google_play.svg new file mode 100644 index 000000000..6cda5f6d3 --- /dev/null +++ b/apps/smp-server/static/media/google_play.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/smp-server/static/media/logo-dark.png b/apps/smp-server/static/media/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1886df819cd104659cc0d3a77c1afc6c83434bee GIT binary patch literal 7383 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 Z0p&QCf+W)r=pFV4JXO>{l*yY1{U7O^mC*nI literal 0 HcmV?d00001 diff --git a/apps/smp-server/static/media/logo-symbol-dark.svg b/apps/smp-server/static/media/logo-symbol-dark.svg new file mode 100644 index 000000000..fa598acf3 --- /dev/null +++ b/apps/smp-server/static/media/logo-symbol-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/smp-server/static/media/logo-symbol-light.svg b/apps/smp-server/static/media/logo-symbol-light.svg new file mode 100644 index 000000000..d8b5951a0 --- /dev/null +++ b/apps/smp-server/static/media/logo-symbol-light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/smp-server/static/media/moon.svg b/apps/smp-server/static/media/moon.svg new file mode 100644 index 000000000..1054735b5 --- /dev/null +++ b/apps/smp-server/static/media/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/smp-server/static/media/qrcode.js b/apps/smp-server/static/media/qrcode.js new file mode 100644 index 000000000..c5c56eb37 --- /dev/null +++ b/apps/smp-server/static/media/qrcode.js @@ -0,0 +1 @@ +var QRCode=function(t){"use strict";var r,e=function(){return"function"==typeof Promise&&Promise.prototype&&Promise.prototype.then},n=[0,26,44,70,100,134,172,196,242,292,346,404,466,532,581,655,733,815,901,991,1085,1156,1258,1364,1474,1588,1706,1828,1921,2051,2185,2323,2465,2611,2761,2876,3034,3196,3362,3532,3706],o=function(t){if(!t)throw new Error('"version" cannot be null or undefined');if(t<1||t>40)throw new Error('"version" should be in range from 1 to 40');return 4*t+17},a=function(t){return n[t]},i=function(t){for(var r=0;0!==t;)r++,t>>>=1;return r},u=function(t){if("function"!=typeof t)throw new Error('"toSJISFunc" is not a valid function.');r=t},s=function(){return void 0!==r},f=function(t){return r(t)};function h(t,r){return t(r={exports:{}},r.exports),r.exports}var c=h((function(t,r){r.L={bit:1},r.M={bit:0},r.Q={bit:3},r.H={bit:2},r.isValid=function(t){return t&&void 0!==t.bit&&t.bit>=0&&t.bit<4},r.from=function(t,e){if(r.isValid(t))return t;try{return function(t){if("string"!=typeof t)throw new Error("Param is not a string");switch(t.toLowerCase()){case"l":case"low":return r.L;case"m":case"medium":return r.M;case"q":case"quartile":return r.Q;case"h":case"high":return r.H;default:throw new Error("Unknown EC Level: "+t)}}(t)}catch(t){return e}}}));function g(){this.buffer=[],this.length=0}c.L,c.M,c.Q,c.H,c.isValid,g.prototype={get:function(t){var r=Math.floor(t/8);return 1==(this.buffer[r]>>>7-t%8&1)},put:function(t,r){for(var e=0;e>>r-e-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var r=Math.floor(this.length/8);this.buffer.length<=r&&this.buffer.push(0),t&&(this.buffer[r]|=128>>>this.length%8),this.length++}};var d=g;function l(t){if(!t||t<1)throw new Error("BitMatrix size must be defined and greater than 0");this.size=t,this.data=new Uint8Array(t*t),this.reservedBit=new Uint8Array(t*t)}l.prototype.set=function(t,r,e,n){var o=t*this.size+r;this.data[o]=e,n&&(this.reservedBit[o]=!0)},l.prototype.get=function(t,r){return this.data[t*this.size+r]},l.prototype.xor=function(t,r,e){this.data[t*this.size+r]^=e},l.prototype.isReserved=function(t,r){return this.reservedBit[t*this.size+r]};var v=l,p=h((function(t,r){var e=o;r.getRowColCoords=function(t){if(1===t)return[];for(var r=Math.floor(t/7)+2,n=e(t),o=145===n?26:2*Math.ceil((n-13)/(2*r-2)),a=[n-7],i=1;i=0&&t<=7},r.from=function(t){return r.isValid(t)?parseInt(t,10):void 0},r.getPenaltyN1=function(t){for(var r=t.size,n=0,o=0,a=0,i=null,u=null,s=0;s=5&&(n+=e+(o-5)),i=h,o=1),(h=t.get(f,s))===u?a++:(a>=5&&(n+=e+(a-5)),u=h,a=1)}o>=5&&(n+=e+(o-5)),a>=5&&(n+=e+(a-5))}return n},r.getPenaltyN2=function(t){for(var r=t.size,e=0,o=0;o=10&&(1488===n||93===n)&&e++,a=a<<1&2047|t.get(u,i),u>=10&&(1488===a||93===a)&&e++}return e*o},r.getPenaltyN4=function(t){for(var r=0,e=t.data.length,n=0;n=0;){for(var n=e[0],o=0;o0){var o=new Uint8Array(this.degree);return o.set(e,n),o}return e};var L=T,b=function(t){return!isNaN(t)&&t>=1&&t<=40},U="(?:[u3000-u303F]|[u3040-u309F]|[u30A0-u30FF]|[uFF00-uFFEF]|[u4E00-u9FAF]|[u2605-u2606]|[u2190-u2195]|u203B|[u2010u2015u2018u2019u2025u2026u201Cu201Du2225u2260]|[u0391-u0451]|[u00A7u00A8u00B1u00B4u00D7u00F7])+",x="(?:(?![A-Z0-9 $%*+\\-./:]|"+(U=U.replace(/u/g,"\\u"))+")(?:.|[\r\n]))+",k=new RegExp(U,"g"),F=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g"),S=new RegExp(x,"g"),D=new RegExp("[0-9]+","g"),Y=new RegExp("[A-Z $%*+\\-./:]+","g"),_=new RegExp("^"+U+"$"),z=new RegExp("^[0-9]+$"),H=new RegExp("^[A-Z0-9 $%*+\\-./:]+$"),J={KANJI:k,BYTE_KANJI:F,BYTE:S,NUMERIC:D,ALPHANUMERIC:Y,testKanji:function(t){return _.test(t)},testNumeric:function(t){return z.test(t)},testAlphanumeric:function(t){return H.test(t)}},K=h((function(t,r){r.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]},r.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]},r.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]},r.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]},r.MIXED={bit:-1},r.getCharCountIndicator=function(t,r){if(!t.ccBits)throw new Error("Invalid mode: "+t);if(!b(r))throw new Error("Invalid version: "+r);return r>=1&&r<10?t.ccBits[0]:r<27?t.ccBits[1]:t.ccBits[2]},r.getBestModeForData=function(t){return J.testNumeric(t)?r.NUMERIC:J.testAlphanumeric(t)?r.ALPHANUMERIC:J.testKanji(t)?r.KANJI:r.BYTE},r.toString=function(t){if(t&&t.id)return t.id;throw new Error("Invalid mode")},r.isValid=function(t){return t&&t.bit&&t.ccBits},r.from=function(t,e){if(r.isValid(t))return t;try{return function(t){if("string"!=typeof t)throw new Error("Param is not a string");switch(t.toLowerCase()){case"numeric":return r.NUMERIC;case"alphanumeric":return r.ALPHANUMERIC;case"kanji":return r.KANJI;case"byte":return r.BYTE;default:throw new Error("Unknown mode: "+t)}}(t)}catch(t){return e}}}));K.NUMERIC,K.ALPHANUMERIC,K.BYTE,K.KANJI,K.MIXED,K.getCharCountIndicator,K.getBestModeForData,K.isValid;var O=h((function(t,r){var e=i(7973);function n(t,r){return K.getCharCountIndicator(t,r)+4}function o(t,r){var e=0;return t.forEach((function(t){var o=n(t.mode,r);e+=o+t.getBitsLength()})),e}r.from=function(t,r){return b(t)?parseInt(t,10):r},r.getCapacity=function(t,r,e){if(!b(t))throw new Error("Invalid QR Code version");void 0===e&&(e=K.BYTE);var o=8*(a(t)-M(t,r));if(e===K.MIXED)return o;var i=o-n(e,t);switch(e){case K.NUMERIC:return Math.floor(i/10*3);case K.ALPHANUMERIC:return Math.floor(i/11*2);case K.KANJI:return Math.floor(i/13);case K.BYTE:default:return Math.floor(i/8)}},r.getBestVersionForData=function(t,e){var n,a=c.from(e,c.M);if(Array.isArray(t)){if(t.length>1)return function(t,e){for(var n=1;n<=40;n++){if(o(t,n)<=r.getCapacity(n,e,K.MIXED))return n}}(t,a);if(0===t.length)return 1;n=t[0]}else n=t;return function(t,e,n){for(var o=1;o<=40;o++)if(e<=r.getCapacity(o,n,t))return o}(n.mode,n.getLength(),a)},r.getEncodedBits=function(t){if(!b(t)||t<7)throw new Error("Invalid QR Code version");for(var r=t<<12;i(r)-e>=0;)r^=7973<=0;)n^=1335<0&&(e=this.data.substr(r),n=parseInt(e,10),t.put(n,3*o+1))};var j=q,$=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function X(t){this.mode=K.ALPHANUMERIC,this.data=t}X.getBitsLength=function(t){return 11*Math.floor(t/2)+t%2*6},X.prototype.getLength=function(){return this.data.length},X.prototype.getBitsLength=function(){return X.getBitsLength(this.data.length)},X.prototype.write=function(t){var r;for(r=0;r+2<=this.data.length;r+=2){var e=45*$.indexOf(this.data[r]);e+=$.indexOf(this.data[r+1]),t.put(e,11)}this.data.length%2&&t.put($.indexOf(this.data[r]),6)};var Z=X;function W(t){this.mode=K.BYTE,"string"==typeof t&&(t=function(t){for(var r=[],e=t.length,n=0;n=55296&&o<=56319&&e>n+1){var a=t.charCodeAt(n+1);a>=56320&&a<=57343&&(o=1024*(o-55296)+a-56320+65536,n+=1)}o<128?r.push(o):o<2048?(r.push(o>>6|192),r.push(63&o|128)):o<55296||o>=57344&&o<65536?(r.push(o>>12|224),r.push(o>>6&63|128),r.push(63&o|128)):o>=65536&&o<=1114111?(r.push(o>>18|240),r.push(o>>12&63|128),r.push(o>>6&63|128),r.push(63&o|128)):r.push(239,191,189)}return new Uint8Array(r).buffer}(t)),this.data=new Uint8Array(t)}W.getBitsLength=function(t){return 8*t},W.prototype.getLength=function(){return this.data.length},W.prototype.getBitsLength=function(){return W.getBitsLength(this.data.length)},W.prototype.write=function(t){for(var r=0,e=this.data.length;r=33088&&e<=40956)e-=33088;else{if(!(e>=57408&&e<=60351))throw new Error("Invalid SJIS character: "+this.data[r]+"\nMake sure your charset is UTF-8");e-=49472}e=192*(e>>>8&255)+(255&e),t.put(e,13)}};var rt=tt,et=h((function(t){var r={single_source_shortest_paths:function(t,e,n){var o={},a={};a[e]=0;var i,u,s,f,h,c,g,d=r.PriorityQueue.make();for(d.push(e,0);!d.empty();)for(s in u=(i=d.pop()).value,f=i.cost,h=t[u]||{})h.hasOwnProperty(s)&&(c=f+h[s],g=a[s],(void 0===a[s]||g>c)&&(a[s]=c,d.push(s,c),o[s]=u));if(void 0!==n&&void 0===a[n]){var l=["Could not find a path from ",e," to ",n,"."].join("");throw new Error(l)}return o},extract_shortest_path_from_predecessor_list:function(t,r){for(var e=[],n=r;n;)e.push(n),n=t[n];return e.reverse(),e},find_path:function(t,e,n){var o=r.single_source_shortest_paths(t,e,n);return r.extract_shortest_path_from_predecessor_list(o,n)},PriorityQueue:{make:function(t){var e,n=r.PriorityQueue,o={};for(e in t=t||{},n)n.hasOwnProperty(e)&&(o[e]=n[e]);return o.queue=[],o.sorter=t.sorter||n.default_sorter,o},default_sorter:function(t,r){return t.cost-r.cost},push:function(t,r){var e={value:t,cost:r};this.queue.push(e),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return 0===this.queue.length}}};t.exports=r})),nt=h((function(t,r){function e(t){return unescape(encodeURIComponent(t)).length}function n(t,r,e){for(var n,o=[];null!==(n=t.exec(e));)o.push({data:n[0],index:n.index,mode:r,length:n[0].length});return o}function o(t){var r,e,o=n(J.NUMERIC,K.NUMERIC,t),a=n(J.ALPHANUMERIC,K.ALPHANUMERIC,t);return s()?(r=n(J.BYTE,K.BYTE,t),e=n(J.KANJI,K.KANJI,t)):(r=n(J.BYTE_KANJI,K.BYTE,t),e=[]),o.concat(a,r,e).sort((function(t,r){return t.index-r.index})).map((function(t){return{data:t.data,mode:t.mode,length:t.length}}))}function a(t,r){switch(r){case K.NUMERIC:return j.getBitsLength(t);case K.ALPHANUMERIC:return Z.getBitsLength(t);case K.KANJI:return rt.getBitsLength(t);case K.BYTE:return G.getBitsLength(t)}}function i(t,r){var e,n=K.getBestModeForData(t);if((e=K.from(r,n))!==K.BYTE&&e.bit=0?t[t.length-1]:null;return e&&e.mode===r.mode?(t[t.length-1].data+=r.data,t):(t.push(r),t)}),[])}(s))},r.rawSplit=function(t){return r.fromArray(o(t))}}));function ot(t,r,e){var n,o,a=t.size,i=V(r,e);for(n=0;n<15;n++)o=1==(i>>n&1),n<6?t.set(n,8,o,!0):n<8?t.set(n+1,8,o,!0):t.set(a-15+n,8,o,!0),n<8?t.set(8,a-n-1,o,!0):n<9?t.set(8,15-n-1+1,o,!0):t.set(8,15-n-1,o,!0);t.set(a-8,8,1,!0)}function at(t,r,e){var n=new d;e.forEach((function(r){n.put(r.mode.bit,4),n.put(r.getLength(),K.getCharCountIndicator(r.mode,t)),r.write(n)}));var o=8*(a(t)-M(t,r));for(n.getLengthInBits()+4<=o&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(0);for(var i=(o-n.getLengthInBits())/8,u=0;u=0&&u<=6&&(0===s||6===s)||s>=0&&s<=6&&(0===u||6===u)||u>=2&&u<=4&&s>=2&&s<=4?t.set(a+u,i+s,!0,!0):t.set(a+u,i+s,!1,!0))}(c,r),function(t){for(var r=t.size,e=8;e=7&&function(t,r){for(var e,n,o,a=t.size,i=O.getEncodedBits(r),u=0;u<18;u++)e=Math.floor(u/3),n=u%3+a-8-3,o=1==(i>>u&1),t.set(e,n,o,!0),t.set(n,e,o,!0)}(c,r),function(t,r){for(var e=t.size,n=-1,o=e-1,a=7,i=0,u=e-1;u>0;u-=2)for(6===u&&u--;;){for(var s=0;s<2;s++)if(!t.isReserved(o,u-s)){var f=!1;i>>a&1)),t.set(o,u-s,f),-1===--a&&(i++,a=7)}if((o+=n)<0||e<=o){o-=n,n=-n;break}}}(c,f),isNaN(n)&&(n=E.getBestMask(c,ot.bind(null,c,e))),E.applyMask(n,c),ot(c,e,n),{modules:c,version:r,errorCorrectionLevel:e,maskPattern:n,segments:a}}nt.fromArray,nt.fromString,nt.rawSplit;var ut=function(t,r){if(void 0===t||""===t)throw new Error("No input text");var e,n,o=c.M;return void 0!==r&&(o=c.from(r.errorCorrectionLevel,c.M),e=O.from(r.version),n=E.from(r.maskPattern),r.toSJISFunc&&u(r.toSJISFunc)),it(t,e,o,n)},st=h((function(t,r){function e(t){if("number"==typeof t&&(t=t.toString()),"string"!=typeof t)throw new Error("Color should be defined as hex string");var r=t.slice().replace("#","").split("");if(r.length<3||5===r.length||r.length>8)throw new Error("Invalid hex color: "+t);3!==r.length&&4!==r.length||(r=Array.prototype.concat.apply([],r.map((function(t){return[t,t]})))),6===r.length&&r.push("F","F");var e=parseInt(r.join(""),16);return{r:e>>24&255,g:e>>16&255,b:e>>8&255,a:255&e,hex:"#"+r.slice(0,6).join("")}}r.getOptions=function(t){t||(t={}),t.color||(t.color={});var r=void 0===t.margin||null===t.margin||t.margin<0?4:t.margin,n=t.width&&t.width>=21?t.width:void 0,o=t.scale||4;return{width:n,scale:n?4:o,margin:r,color:{dark:e(t.color.dark||"#000000ff"),light:e(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}},r.getScale=function(t,r){return r.width&&r.width>=t+2*r.margin?r.width/(t+2*r.margin):r.scale},r.getImageWidth=function(t,e){var n=r.getScale(t,e);return Math.floor((t+2*e.margin)*n)},r.qrToImageData=function(t,e,n){for(var o=e.modules.size,a=e.modules.data,i=r.getScale(o,n),u=Math.floor((o+2*n.margin)*i),s=n.margin*i,f=[n.color.light,n.color.dark],h=0;h=s&&c>=s&&h':"",s="0&&s>0&&t[u-1]||(n+=a?ct("M",s+e,.5+f+e):ct("m",o,0),o=0,a=!1),s+1',f='viewBox="0 0 '+i+" "+i+'"',h=''+u+s+"\n";return"function"==typeof e&&e(null,h),h};function dt(t,r,n,o,a){var i=[].slice.call(arguments,1),u=i.length,s="function"==typeof i[u-1];if(!s&&!e())throw new Error("Callback required as last argument");if(!s){if(u<1)throw new Error("Too few arguments provided");return 1===u?(n=r,r=o=void 0):2!==u||r.getContext||(o=n,n=r,r=void 0),new Promise((function(e,a){try{var i=ut(n,o);e(t(i,r,o))}catch(t){a(t)}}))}if(u<2)throw new Error("Too few arguments provided");2===u?(a=n,n=r,r=o=void 0):3===u&&(r.getContext&&void 0===a?(a=o,o=void 0):(a=o,o=n,n=r,r=void 0));try{var f=ut(n,o);a(null,t(f,r,o))}catch(t){a(t)}}var lt=ut,vt=dt.bind(null,ft.render),pt=dt.bind(null,ft.renderToDataURL),wt=dt.bind(null,(function(t,r,e){return gt(t,e)})),mt={create:lt,toCanvas:vt,toDataURL:pt,toString:wt};return t.create=lt,t.default=mt,t.toCanvas=vt,t.toDataURL=pt,t.toString=wt,Object.defineProperty(t,"__esModule",{value:!0}),t}({}); diff --git a/apps/smp-server/static/media/script.js b/apps/smp-server/static/media/script.js new file mode 100644 index 000000000..aa5d35293 --- /dev/null +++ b/apps/smp-server/static/media/script.js @@ -0,0 +1,39 @@ +const isMobile = { + Android: () => navigator.userAgent.match(/Android/i), + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i) +}; + +window.addEventListener('click', clickHandler) + +if (isMobile.iOS) { + for (const btn of document.getElementsByClassName("close-overlay-btn")) { + btn.addEventListener("touchend", (e) => setTimeout(() => closeOverlay(e), 100)) + } +} + +function clickHandler(e) { + if (e.target.closest('.contact-tab-btn')) { + e.target.closest('.contact-tab').classList.toggle('active') + } +} + +window.addEventListener('load', () => { + const googlePlayBtn = document.querySelector('.google-play-btn'); + const appleStoreBtn = document.querySelector('.apple-store-btn'); + const fDroidBtn = document.querySelector('.f-droid-btn'); + if (!googlePlayBtn || !appleStoreBtn || !fDroidBtn) return; + + + if (isMobile.Android()) { + googlePlayBtn.classList.remove('hidden'); + fDroidBtn.classList.remove('hidden'); + } + else if (isMobile.iOS()) { + appleStoreBtn.classList.remove('hidden'); + } + else { + appleStoreBtn.classList.remove('hidden'); + googlePlayBtn.classList.remove('hidden'); + fDroidBtn.classList.remove('hidden'); + } +}) diff --git a/apps/smp-server/static/media/style.css b/apps/smp-server/static/media/style.css new file mode 100644 index 000000000..d9d2e829e --- /dev/null +++ b/apps/smp-server/static/media/style.css @@ -0,0 +1,410 @@ +@font-face { + font-family: Gilroy; + src: url("GilroyRegular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: Gilroy; + src: url("GilroyLight.woff2") format("woff2"); + font-weight: 300; + font-style: normal; +} + +@font-face { + font-family: Gilroy; + src: url("GilroyMedium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: Gilroy; + src: url("GilroyBold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: Gilroy; + src: url("GilroyRegularItalic.woff2") format("woff2"); + font-weight: 400; + font-style: italic; +} + +html { + scroll-behavior: smooth; + font-family: Gilroy, Helvetica, sans-serif; + ; + letter-spacing: 0.003em; +} + +img { + user-select: none; + -webkit-user-select: none; + /* For Safari and older Chrome versions */ + -moz-user-select: none; + /* For Firefox */ + -ms-user-select: none; + /* For Internet Explorer and Edge */ +} + +/* NEW SITE */ +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + width: 100%; + /* padding: 0 20px; */ + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + + .container-sm, + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + + .container-md, + .container-sm, + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + + .container-lg, + .container-md, + .container-sm, + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + + .container-xl, + .container-lg, + .container-md, + .container-sm, + .container { + max-width: 1140px; + } +} + +@media (min-width: 1400px) { + + .container-xxl, + .container-xl, + .container-lg, + .container-md, + .container-sm, + .container { + max-width: 1320px; + } +} + +.gradient-text { + background: -webkit-linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%); + background: linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; +} + +.dark .border-gradient { + background: + linear-gradient(#11182F, #11182F) padding-box, + linear-gradient(to bottom, transparent, #01F1FF 58%) border-box; + border: 1px solid transparent; +} + +.dark .only-light { + display: none; +} + +.only-dark { + display: none; +} + +.dark .only-dark { + display: inherit; +} + +.menu-link { + font-size: 16px; + line-height: 33.42px; + color: #0D0E12; +} + +.dark .menu-link { + color: #fff; +} + +.nav-link ul li a.active { + color: #0053D0; + +} + +.dark .nav-link ul li a.active { + color: #66D9E2; +} + +@media (min-width:1024px) { + + .nav-link-text, + .menu-link { + display: inline-block; + position: relative; + color: #0D0E12; + } + + .nav-link-text::before, + .active .nav-link-text::before, + .menu-link::before { + content: ""; + position: absolute; + width: 0; + height: 1px; + bottom: 0; + right: 0; + /* background-color: initial; */ + transition: width 0.25s ease-out; + } + + .menu-link::before { + background-color: #0D0E12; + } + + .dark .menu-link::before { + background-color: #fff; + } + + .active .nav-link-text::before { + width: 100%; + } + + .nav-link:hover .nav-link-text::before, + .menu-link:hover::before { + width: 100%; + left: 0; + right: auto; + } +} + + +.sub-menu { + visibility: hidden; + opacity: 0; + color: #505158; +} + +.sub-menu .no-hover { + color: #505158 !important; +} + +.dark .sub-menu, +.dark .sub-menu .no-hover { + color: #fff !important; +} + +.dark .sub-menu li:hover { + color: #66D9E2; +} + +.sub-menu li:hover { + color: #0053D0; +} + +.sub-menu { + transition: all .3s ease !important; +} + +.nav-link span svg, +header nav { + transition: all 0.5s ease; +} + +.nav-link:hover span svg { + transform: rotate(180deg); +} + +@media (min-width:1024px) { + + .nav-link:hover .sub-menu, + .nav-link:focus-within .sub-menu { + visibility: visible; + opacity: 1; + margin-top: 0; + } +} + +@media (max-width: 1024px) { + .sub-menu { + max-height: 0; + transform: translateY(-10px); + transition: all .7s ease !important; + } + + .active .sub-menu { + max-height: 600px; + transform: translateY(0px); + opacity: 1; + visibility: visible; + margin-top: 0; + } + + header nav { + visibility: hidden; + opacity: 0; + transform: translateX(100%); + } + + header nav.open { + visibility: visible; + opacity: 1; + transform: translateX(0); + } +} + +.lock-scroll { + overflow: hidden; +} + +/* hero */ +header { + transition: all .7s ease; +} + +.primary-header { + background: linear-gradient(270deg, #0053D0 35.85%, #0197FF 94.78%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0px 4px 74px #e9e7e2; +} + +.dark .primary-header { + background: linear-gradient(270deg, #70F0F9 100%, #70F0F9 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: none; +} + +.secondary-header { + color: #606c71; + text-shadow: 0px 4px 74px #e9e7e2; +} + +.dark .secondary-header { + color: #fff; + text-shadow: none; +} + +.description { + width: 31rem; +} + +p a { + color: #0053D0; + text-decoration: underline; + text-underline-offset: 2px; +} + +.dark p a { + color: #70F0F9; +} + +/* For Contact & Invitation Page */ +.primary-header-contact { + background: linear-gradient(251.16deg, #53c1ff 1.1%, #0053d0 100.82%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0px 4px 74px #e9e7e2; +} + +.dark .primary-header-contact { + background: linear-gradient(270deg, #70F0F9 100%, #70F0F9 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: none; +} + +.secondary-header-contact { + text-shadow: 0px 4px 74px #e9e7e2; +} + +.dark .secondary-header-contact { + text-shadow: none; +} + +.content_copy_with_tooltip { + background-color: #f8f8f6; + border-radius: 50px; + padding-bottom: 4px; + padding-top: 8px; + margin-top: 16px; + margin-bottom: 16px; +} + +.content_copy_with_tooltip .tooltip { + vertical-align: -6px; +} + +.content_copy_with_tooltip .content { + font-size: 15px; +} + +.contact-tab>.contact-tab-content, +.job-tab>.job-tab-content { + opacity: 0; + max-height: 0; + transition: all 0.5s ease; + visibility: hidden; + transform: translateY(10px); + overflow: hidden; +} + +.contact-tab svg, +.job-tab svg { + transform: rotate(-180deg); + transition: all .5s ease; +} + +.contact-tab.active>.contact-tab-content, +.job-tab.active>.job-tab-content { + opacity: 1; + max-height: 300px; + visibility: visible; + transform: translateY(0px); +} + +.for-tablet .contact-tab.active>.contact-tab-content, +.for-tablet .job-tab.active>.job-tab-content { + min-height: 450px; +} + +.contact-tab.active svg, +.contact-tab:hover svg, +.job-tab.active svg, +.job-tab:hover svg { + transform: rotate(0deg); +} + +.d-none-if-js-disabled { + display: none !important; +} \ No newline at end of file diff --git a/apps/smp-server/static/media/sun.svg b/apps/smp-server/static/media/sun.svg new file mode 100644 index 000000000..8407b98e5 --- /dev/null +++ b/apps/smp-server/static/media/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/smp-server/static/media/swiper-bundle.min.css b/apps/smp-server/static/media/swiper-bundle.min.css new file mode 100644 index 000000000..916173ac0 --- /dev/null +++ b/apps/smp-server/static/media/swiper-bundle.min.css @@ -0,0 +1,13 @@ +/** + * Swiper 8.4.3 + * Most modern mobile touch slider and framework with hardware accelerated transitions + * https://swiperjs.com + * + * Copyright 2014-2022 Vladimir Kharlampidi + * + * Released under the MIT License + * + * Released on: October 6, 2022 + */ + +@font-face{font-family:swiper-icons;src:url('data:application/font-woff;charset=utf-8;base64, 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');font-weight:400;font-style:normal}:root{--swiper-theme-color:#007aff}.swiper{margin-left:auto;margin-right:auto;position:relative;overflow:hidden;list-style:none;padding:0;z-index:1}.swiper-vertical>.swiper-wrapper{flex-direction:column}.swiper-wrapper{position:relative;width:100%;height:100%;z-index:1;display:flex;transition-property:transform;box-sizing:content-box}.swiper-android .swiper-slide,.swiper-wrapper{transform:translate3d(0px,0,0)}.swiper-pointer-events{touch-action:pan-y}.swiper-pointer-events.swiper-vertical{touch-action:pan-x}.swiper-slide{flex-shrink:0;width:100%;height:100%;position:relative;transition-property:transform}.swiper-slide-invisible-blank{visibility:hidden}.swiper-autoheight,.swiper-autoheight .swiper-slide{height:auto}.swiper-autoheight .swiper-wrapper{align-items:flex-start;transition-property:transform,height}.swiper-backface-hidden .swiper-slide{transform:translateZ(0);-webkit-backface-visibility:hidden;backface-visibility:hidden}.swiper-3d,.swiper-3d.swiper-css-mode .swiper-wrapper{perspective:1200px}.swiper-3d .swiper-cube-shadow,.swiper-3d .swiper-slide,.swiper-3d .swiper-slide-shadow,.swiper-3d .swiper-slide-shadow-bottom,.swiper-3d .swiper-slide-shadow-left,.swiper-3d .swiper-slide-shadow-right,.swiper-3d .swiper-slide-shadow-top,.swiper-3d .swiper-wrapper{transform-style:preserve-3d}.swiper-3d .swiper-slide-shadow,.swiper-3d .swiper-slide-shadow-bottom,.swiper-3d .swiper-slide-shadow-left,.swiper-3d .swiper-slide-shadow-right,.swiper-3d .swiper-slide-shadow-top{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:10}.swiper-3d .swiper-slide-shadow{background:rgba(0,0,0,.15)}.swiper-3d .swiper-slide-shadow-left{background-image:linear-gradient(to left,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-3d .swiper-slide-shadow-right{background-image:linear-gradient(to right,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-3d .swiper-slide-shadow-top{background-image:linear-gradient(to top,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-3d .swiper-slide-shadow-bottom{background-image:linear-gradient(to bottom,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-css-mode>.swiper-wrapper{overflow:auto;scrollbar-width:none;-ms-overflow-style:none}.swiper-css-mode>.swiper-wrapper::-webkit-scrollbar{display:none}.swiper-css-mode>.swiper-wrapper>.swiper-slide{scroll-snap-align:start start}.swiper-horizontal.swiper-css-mode>.swiper-wrapper{scroll-snap-type:x mandatory}.swiper-vertical.swiper-css-mode>.swiper-wrapper{scroll-snap-type:y mandatory}.swiper-centered>.swiper-wrapper::before{content:'';flex-shrink:0;order:9999}.swiper-centered.swiper-horizontal>.swiper-wrapper>.swiper-slide:first-child{margin-inline-start:var(--swiper-centered-offset-before)}.swiper-centered.swiper-horizontal>.swiper-wrapper::before{height:100%;min-height:1px;width:var(--swiper-centered-offset-after)}.swiper-centered.swiper-vertical>.swiper-wrapper>.swiper-slide:first-child{margin-block-start:var(--swiper-centered-offset-before)}.swiper-centered.swiper-vertical>.swiper-wrapper::before{width:100%;min-width:1px;height:var(--swiper-centered-offset-after)}.swiper-centered>.swiper-wrapper>.swiper-slide{scroll-snap-align:center center}.swiper-virtual .swiper-slide{-webkit-backface-visibility:hidden;transform:translateZ(0)}.swiper-virtual.swiper-css-mode .swiper-wrapper::after{content:'';position:absolute;left:0;top:0;pointer-events:none}.swiper-virtual.swiper-css-mode.swiper-horizontal .swiper-wrapper::after{height:1px;width:var(--swiper-virtual-size)}.swiper-virtual.swiper-css-mode.swiper-vertical .swiper-wrapper::after{width:1px;height:var(--swiper-virtual-size)}:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:50%;width:calc(var(--swiper-navigation-size)/ 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(0px - (var(--swiper-navigation-size)/ 2));z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next.swiper-button-hidden,.swiper-button-prev.swiper-button-hidden{opacity:0;cursor:auto;pointer-events:none}.swiper-navigation-disabled .swiper-button-next,.swiper-navigation-disabled .swiper-button-prev{display:none!important}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;font-variant:initial;line-height:1}.swiper-button-prev,.swiper-rtl .swiper-button-next{left:10px;right:auto}.swiper-button-prev:after,.swiper-rtl .swiper-button-next:after{content:'prev'}.swiper-button-next,.swiper-rtl .swiper-button-prev{right:10px;left:auto}.swiper-button-next:after,.swiper-rtl .swiper-button-prev:after{content:'next'}.swiper-button-lock{display:none}.swiper-pagination{position:absolute;text-align:center;transition:.3s opacity;transform:translate3d(0,0,0);z-index:10}.swiper-pagination.swiper-pagination-hidden{opacity:0}.swiper-pagination-disabled>.swiper-pagination,.swiper-pagination.swiper-pagination-disabled{display:none!important}.swiper-horizontal>.swiper-pagination-bullets,.swiper-pagination-bullets.swiper-pagination-horizontal,.swiper-pagination-custom,.swiper-pagination-fraction{bottom:10px;left:0;width:100%}.swiper-pagination-bullets-dynamic{overflow:hidden;font-size:0}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{transform:scale(.33);position:relative}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active{transform:scale(1)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-main{transform:scale(1)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev{transform:scale(.66)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev-prev{transform:scale(.33)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next{transform:scale(.66)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next-next{transform:scale(.33)}.swiper-pagination-bullet{width:var(--swiper-pagination-bullet-width,var(--swiper-pagination-bullet-size,8px));height:var(--swiper-pagination-bullet-height,var(--swiper-pagination-bullet-size,8px));display:inline-block;border-radius:50%;background:var(--swiper-pagination-bullet-inactive-color,#000);opacity:var(--swiper-pagination-bullet-inactive-opacity, .2)}button.swiper-pagination-bullet{border:none;margin:0;padding:0;box-shadow:none;-webkit-appearance:none;appearance:none}.swiper-pagination-clickable .swiper-pagination-bullet{cursor:pointer}.swiper-pagination-bullet:only-child{display:none!important}.swiper-pagination-bullet-active{opacity:var(--swiper-pagination-bullet-opacity, 1);background:var(--swiper-pagination-color,var(--swiper-theme-color))}.swiper-pagination-vertical.swiper-pagination-bullets,.swiper-vertical>.swiper-pagination-bullets{right:10px;top:50%;transform:translate3d(0px,-50%,0)}.swiper-pagination-vertical.swiper-pagination-bullets .swiper-pagination-bullet,.swiper-vertical>.swiper-pagination-bullets .swiper-pagination-bullet{margin:var(--swiper-pagination-bullet-vertical-gap,6px) 0;display:block}.swiper-pagination-vertical.swiper-pagination-bullets.swiper-pagination-bullets-dynamic,.swiper-vertical>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic{top:50%;transform:translateY(-50%);width:8px}.swiper-pagination-vertical.swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet,.swiper-vertical>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{display:inline-block;transition:.2s transform,.2s top}.swiper-horizontal>.swiper-pagination-bullets .swiper-pagination-bullet,.swiper-pagination-horizontal.swiper-pagination-bullets .swiper-pagination-bullet{margin:0 var(--swiper-pagination-bullet-horizontal-gap,4px)}.swiper-horizontal>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic,.swiper-pagination-horizontal.swiper-pagination-bullets.swiper-pagination-bullets-dynamic{left:50%;transform:translateX(-50%);white-space:nowrap}.swiper-horizontal>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet,.swiper-pagination-horizontal.swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{transition:.2s transform,.2s left}.swiper-horizontal.swiper-rtl>.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{transition:.2s transform,.2s right}.swiper-pagination-progressbar{background:rgba(0,0,0,.25);position:absolute}.swiper-pagination-progressbar .swiper-pagination-progressbar-fill{background:var(--swiper-pagination-color,var(--swiper-theme-color));position:absolute;left:0;top:0;width:100%;height:100%;transform:scale(0);transform-origin:left top}.swiper-rtl .swiper-pagination-progressbar .swiper-pagination-progressbar-fill{transform-origin:right top}.swiper-horizontal>.swiper-pagination-progressbar,.swiper-pagination-progressbar.swiper-pagination-horizontal,.swiper-pagination-progressbar.swiper-pagination-vertical.swiper-pagination-progressbar-opposite,.swiper-vertical>.swiper-pagination-progressbar.swiper-pagination-progressbar-opposite{width:100%;height:4px;left:0;top:0}.swiper-horizontal>.swiper-pagination-progressbar.swiper-pagination-progressbar-opposite,.swiper-pagination-progressbar.swiper-pagination-horizontal.swiper-pagination-progressbar-opposite,.swiper-pagination-progressbar.swiper-pagination-vertical,.swiper-vertical>.swiper-pagination-progressbar{width:4px;height:100%;left:0;top:0}.swiper-pagination-lock{display:none}.swiper-scrollbar{border-radius:10px;position:relative;-ms-touch-action:none;background:rgba(0,0,0,.1)}.swiper-scrollbar-disabled>.swiper-scrollbar,.swiper-scrollbar.swiper-scrollbar-disabled{display:none!important}.swiper-horizontal>.swiper-scrollbar,.swiper-scrollbar.swiper-scrollbar-horizontal{position:absolute;left:1%;bottom:3px;z-index:50;height:5px;width:98%}.swiper-scrollbar.swiper-scrollbar-vertical,.swiper-vertical>.swiper-scrollbar{position:absolute;right:3px;top:1%;z-index:50;width:5px;height:98%}.swiper-scrollbar-drag{height:100%;width:100%;position:relative;background:rgba(0,0,0,.5);border-radius:10px;left:0;top:0}.swiper-scrollbar-cursor-drag{cursor:move}.swiper-scrollbar-lock{display:none}.swiper-zoom-container{width:100%;height:100%;display:flex;justify-content:center;align-items:center;text-align:center}.swiper-zoom-container>canvas,.swiper-zoom-container>img,.swiper-zoom-container>svg{max-width:100%;max-height:100%;object-fit:contain}.swiper-slide-zoomed{cursor:move}.swiper-lazy-preloader{width:42px;height:42px;position:absolute;left:50%;top:50%;margin-left:-21px;margin-top:-21px;z-index:10;transform-origin:50%;box-sizing:border-box;border:4px solid var(--swiper-preloader-color,var(--swiper-theme-color));border-radius:50%;border-top-color:transparent}.swiper-watch-progress .swiper-slide-visible .swiper-lazy-preloader,.swiper:not(.swiper-watch-progress) .swiper-lazy-preloader{animation:swiper-preloader-spin 1s infinite linear}.swiper-lazy-preloader-white{--swiper-preloader-color:#fff}.swiper-lazy-preloader-black{--swiper-preloader-color:#000}@keyframes swiper-preloader-spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.swiper .swiper-notification{position:absolute;left:0;top:0;pointer-events:none;opacity:0;z-index:-1000}.swiper-free-mode>.swiper-wrapper{transition-timing-function:ease-out;margin:0 auto}.swiper-grid>.swiper-wrapper{flex-wrap:wrap}.swiper-grid-column>.swiper-wrapper{flex-wrap:wrap;flex-direction:column}.swiper-fade.swiper-free-mode .swiper-slide{transition-timing-function:ease-out}.swiper-fade .swiper-slide{pointer-events:none;transition-property:opacity}.swiper-fade .swiper-slide .swiper-slide{pointer-events:none}.swiper-fade .swiper-slide-active,.swiper-fade .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-cube{overflow:visible}.swiper-cube .swiper-slide{pointer-events:none;-webkit-backface-visibility:hidden;backface-visibility:hidden;z-index:1;visibility:hidden;transform-origin:0 0;width:100%;height:100%}.swiper-cube .swiper-slide .swiper-slide{pointer-events:none}.swiper-cube.swiper-rtl .swiper-slide{transform-origin:100% 0}.swiper-cube .swiper-slide-active,.swiper-cube .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-cube .swiper-slide-active,.swiper-cube .swiper-slide-next,.swiper-cube .swiper-slide-next+.swiper-slide,.swiper-cube .swiper-slide-prev{pointer-events:auto;visibility:visible}.swiper-cube .swiper-slide-shadow-bottom,.swiper-cube .swiper-slide-shadow-left,.swiper-cube .swiper-slide-shadow-right,.swiper-cube .swiper-slide-shadow-top{z-index:0;-webkit-backface-visibility:hidden;backface-visibility:hidden}.swiper-cube .swiper-cube-shadow{position:absolute;left:0;bottom:0px;width:100%;height:100%;opacity:.6;z-index:0}.swiper-cube .swiper-cube-shadow:before{content:'';background:#000;position:absolute;left:0;top:0;bottom:0;right:0;filter:blur(50px)}.swiper-flip{overflow:visible}.swiper-flip .swiper-slide{pointer-events:none;-webkit-backface-visibility:hidden;backface-visibility:hidden;z-index:1}.swiper-flip .swiper-slide .swiper-slide{pointer-events:none}.swiper-flip .swiper-slide-active,.swiper-flip .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-flip .swiper-slide-shadow-bottom,.swiper-flip .swiper-slide-shadow-left,.swiper-flip .swiper-slide-shadow-right,.swiper-flip .swiper-slide-shadow-top{z-index:0;-webkit-backface-visibility:hidden;backface-visibility:hidden}.swiper-creative .swiper-slide{-webkit-backface-visibility:hidden;backface-visibility:hidden;overflow:hidden;transition-property:transform,opacity,height}.swiper-cards{overflow:visible}.swiper-cards .swiper-slide{transform-origin:center bottom;-webkit-backface-visibility:hidden;backface-visibility:hidden;overflow:hidden} \ No newline at end of file diff --git a/apps/smp-server/static/media/swiper-bundle.min.js b/apps/smp-server/static/media/swiper-bundle.min.js new file mode 100644 index 000000000..0c347e3e0 --- /dev/null +++ b/apps/smp-server/static/media/swiper-bundle.min.js @@ -0,0 +1,14 @@ +/** + * Swiper 8.4.3 + * Most modern mobile touch slider and framework with hardware accelerated transitions + * https://swiperjs.com + * + * Copyright 2014-2022 Vladimir Kharlampidi + * + * Released under the MIT License + * + * Released on: October 6, 2022 + */ + +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Swiper=t()}(this,(function(){"use strict";function e(e){return null!==e&&"object"==typeof e&&"constructor"in e&&e.constructor===Object}function t(s,a){void 0===s&&(s={}),void 0===a&&(a={}),Object.keys(a).forEach((i=>{void 0===s[i]?s[i]=a[i]:e(a[i])&&e(s[i])&&Object.keys(a[i]).length>0&&t(s[i],a[i])}))}const s={body:{},addEventListener(){},removeEventListener(){},activeElement:{blur(){},nodeName:""},querySelector:()=>null,querySelectorAll:()=>[],getElementById:()=>null,createEvent:()=>({initEvent(){}}),createElement:()=>({children:[],childNodes:[],style:{},setAttribute(){},getElementsByTagName:()=>[]}),createElementNS:()=>({}),importNode:()=>null,location:{hash:"",host:"",hostname:"",href:"",origin:"",pathname:"",protocol:"",search:""}};function a(){const e="undefined"!=typeof document?document:{};return t(e,s),e}const i={document:s,navigator:{userAgent:""},location:{hash:"",host:"",hostname:"",href:"",origin:"",pathname:"",protocol:"",search:""},history:{replaceState(){},pushState(){},go(){},back(){}},CustomEvent:function(){return this},addEventListener(){},removeEventListener(){},getComputedStyle:()=>({getPropertyValue:()=>""}),Image(){},Date(){},screen:{},setTimeout(){},clearTimeout(){},matchMedia:()=>({}),requestAnimationFrame:e=>"undefined"==typeof setTimeout?(e(),null):setTimeout(e,0),cancelAnimationFrame(e){"undefined"!=typeof setTimeout&&clearTimeout(e)}};function r(){const e="undefined"!=typeof window?window:{};return t(e,i),e}class n extends Array{constructor(e){"number"==typeof e?super(e):(super(...e||[]),function(e){const t=e.__proto__;Object.defineProperty(e,"__proto__",{get:()=>t,set(e){t.__proto__=e}})}(this))}}function l(e){void 0===e&&(e=[]);const t=[];return e.forEach((e=>{Array.isArray(e)?t.push(...l(e)):t.push(e)})),t}function o(e,t){return Array.prototype.filter.call(e,t)}function d(e,t){const s=r(),i=a();let l=[];if(!t&&e instanceof n)return e;if(!e)return new n(l);if("string"==typeof e){const s=e.trim();if(s.indexOf("<")>=0&&s.indexOf(">")>=0){let e="div";0===s.indexOf("e.split(" "))));return this.forEach((e=>{e.classList.add(...a)})),this},removeClass:function(){for(var e=arguments.length,t=new Array(e),s=0;se.split(" "))));return this.forEach((e=>{e.classList.remove(...a)})),this},hasClass:function(){for(var e=arguments.length,t=new Array(e),s=0;se.split(" "))));return o(this,(e=>a.filter((t=>e.classList.contains(t))).length>0)).length>0},toggleClass:function(){for(var e=arguments.length,t=new Array(e),s=0;se.split(" "))));this.forEach((e=>{a.forEach((t=>{e.classList.toggle(t)}))}))},attr:function(e,t){if(1===arguments.length&&"string"==typeof e)return this[0]?this[0].getAttribute(e):void 0;for(let s=0;s=0;e-=1){const i=a[e];r&&i.listener===r||r&&i.listener&&i.listener.dom7proxy&&i.listener.dom7proxy===r?(s.removeEventListener(t,i.proxyListener,n),a.splice(e,1)):r||(s.removeEventListener(t,i.proxyListener,n),a.splice(e,1))}}}return this},trigger:function(){const e=r();for(var t=arguments.length,s=new Array(t),a=0;at>0)),i.dispatchEvent(t),i.dom7EventData=[],delete i.dom7EventData}}}return this},transitionEnd:function(e){const t=this;return e&&t.on("transitionend",(function s(a){a.target===this&&(e.call(this,a),t.off("transitionend",s))})),this},outerWidth:function(e){if(this.length>0){if(e){const e=this.styles();return this[0].offsetWidth+parseFloat(e.getPropertyValue("margin-right"))+parseFloat(e.getPropertyValue("margin-left"))}return this[0].offsetWidth}return null},outerHeight:function(e){if(this.length>0){if(e){const e=this.styles();return this[0].offsetHeight+parseFloat(e.getPropertyValue("margin-top"))+parseFloat(e.getPropertyValue("margin-bottom"))}return this[0].offsetHeight}return null},styles:function(){const e=r();return this[0]?e.getComputedStyle(this[0],null):{}},offset:function(){if(this.length>0){const e=r(),t=a(),s=this[0],i=s.getBoundingClientRect(),n=t.body,l=s.clientTop||n.clientTop||0,o=s.clientLeft||n.clientLeft||0,d=s===e?e.scrollY:s.scrollTop,c=s===e?e.scrollX:s.scrollLeft;return{top:i.top+d-l,left:i.left+c-o}}return null},css:function(e,t){const s=r();let a;if(1===arguments.length){if("string"!=typeof e){for(a=0;a{e.apply(t,[t,s])})),this):this},html:function(e){if(void 0===e)return this[0]?this[0].innerHTML:null;for(let t=0;tt-1)return d([]);if(e<0){const s=t+e;return d(s<0?[]:[this[s]])}return d([this[e]])},append:function(){let e;const t=a();for(let s=0;s=0;i-=1)this[s].insertBefore(a.childNodes[i],this[s].childNodes[0])}else if(e instanceof n)for(i=0;i0?e?this[0].nextElementSibling&&d(this[0].nextElementSibling).is(e)?d([this[0].nextElementSibling]):d([]):this[0].nextElementSibling?d([this[0].nextElementSibling]):d([]):d([])},nextAll:function(e){const t=[];let s=this[0];if(!s)return d([]);for(;s.nextElementSibling;){const a=s.nextElementSibling;e?d(a).is(e)&&t.push(a):t.push(a),s=a}return d(t)},prev:function(e){if(this.length>0){const t=this[0];return e?t.previousElementSibling&&d(t.previousElementSibling).is(e)?d([t.previousElementSibling]):d([]):t.previousElementSibling?d([t.previousElementSibling]):d([])}return d([])},prevAll:function(e){const t=[];let s=this[0];if(!s)return d([]);for(;s.previousElementSibling;){const a=s.previousElementSibling;e?d(a).is(e)&&t.push(a):t.push(a),s=a}return d(t)},parent:function(e){const t=[];for(let s=0;s6&&(i=i.split(", ").map((e=>e.replace(",","."))).join(", ")),n=new s.WebKitCSSMatrix("none"===i?"":i)):(n=l.MozTransform||l.OTransform||l.MsTransform||l.msTransform||l.transform||l.getPropertyValue("transform").replace("translate(","matrix(1, 0, 0, 1,"),a=n.toString().split(",")),"x"===t&&(i=s.WebKitCSSMatrix?n.m41:16===a.length?parseFloat(a[12]):parseFloat(a[4])),"y"===t&&(i=s.WebKitCSSMatrix?n.m42:16===a.length?parseFloat(a[13]):parseFloat(a[5])),i||0}function m(e){return"object"==typeof e&&null!==e&&e.constructor&&"Object"===Object.prototype.toString.call(e).slice(8,-1)}function f(e){return"undefined"!=typeof window&&void 0!==window.HTMLElement?e instanceof HTMLElement:e&&(1===e.nodeType||11===e.nodeType)}function g(){const e=Object(arguments.length<=0?void 0:arguments[0]),t=["__proto__","constructor","prototype"];for(let s=1;st.indexOf(e)<0));for(let t=0,i=s.length;tn?"next":"prev",p=(e,t)=>"next"===c&&e>=t||"prev"===c&&e<=t,u=()=>{l=(new Date).getTime(),null===o&&(o=l);const e=Math.max(Math.min((l-o)/d,1),0),r=.5-Math.cos(e*Math.PI)/2;let c=n+r*(s-n);if(p(c,s)&&(c=s),t.wrapperEl.scrollTo({[a]:c}),p(c,s))return t.wrapperEl.style.overflow="hidden",t.wrapperEl.style.scrollSnapType="",setTimeout((()=>{t.wrapperEl.style.overflow="",t.wrapperEl.scrollTo({[a]:c})})),void i.cancelAnimationFrame(t.cssModeFrameID);t.cssModeFrameID=i.requestAnimationFrame(u)};u()}let b,x,y;function E(){return b||(b=function(){const e=r(),t=a();return{smoothScroll:t.documentElement&&"scrollBehavior"in t.documentElement.style,touch:!!("ontouchstart"in e||e.DocumentTouch&&t instanceof e.DocumentTouch),passiveListener:function(){let t=!1;try{const s=Object.defineProperty({},"passive",{get(){t=!0}});e.addEventListener("testPassiveListener",null,s)}catch(e){}return t}(),gestures:"ongesturestart"in e}}()),b}function C(e){return void 0===e&&(e={}),x||(x=function(e){let{userAgent:t}=void 0===e?{}:e;const s=E(),a=r(),i=a.navigator.platform,n=t||a.navigator.userAgent,l={ios:!1,android:!1},o=a.screen.width,d=a.screen.height,c=n.match(/(Android);?[\s\/]+([\d.]+)?/);let p=n.match(/(iPad).*OS\s([\d_]+)/);const u=n.match(/(iPod)(.*OS\s([\d_]+))?/),h=!p&&n.match(/(iPhone\sOS|iOS)\s([\d_]+)/),m="Win32"===i;let f="MacIntel"===i;return!p&&f&&s.touch&&["1024x1366","1366x1024","834x1194","1194x834","834x1112","1112x834","768x1024","1024x768","820x1180","1180x820","810x1080","1080x810"].indexOf(`${o}x${d}`)>=0&&(p=n.match(/(Version)\/([\d.]+)/),p||(p=[0,1,"13_0_0"]),f=!1),c&&!m&&(l.os="android",l.android=!0),(p||h||u)&&(l.os="ios",l.ios=!0),l}(e)),x}function T(){return y||(y=function(){const e=r();return{isSafari:function(){const t=e.navigator.userAgent.toLowerCase();return t.indexOf("safari")>=0&&t.indexOf("chrome")<0&&t.indexOf("android")<0}(),isWebView:/(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/i.test(e.navigator.userAgent)}}()),y}Object.keys(c).forEach((e=>{Object.defineProperty(d.fn,e,{value:c[e],writable:!0})}));var $={on(e,t,s){const a=this;if(!a.eventsListeners||a.destroyed)return a;if("function"!=typeof t)return a;const i=s?"unshift":"push";return e.split(" ").forEach((e=>{a.eventsListeners[e]||(a.eventsListeners[e]=[]),a.eventsListeners[e][i](t)})),a},once(e,t,s){const a=this;if(!a.eventsListeners||a.destroyed)return a;if("function"!=typeof t)return a;function i(){a.off(e,i),i.__emitterProxy&&delete i.__emitterProxy;for(var s=arguments.length,r=new Array(s),n=0;n=0&&t.eventsAnyListeners.splice(s,1),t},off(e,t){const s=this;return!s.eventsListeners||s.destroyed?s:s.eventsListeners?(e.split(" ").forEach((e=>{void 0===t?s.eventsListeners[e]=[]:s.eventsListeners[e]&&s.eventsListeners[e].forEach(((a,i)=>{(a===t||a.__emitterProxy&&a.__emitterProxy===t)&&s.eventsListeners[e].splice(i,1)}))})),s):s},emit(){const e=this;if(!e.eventsListeners||e.destroyed)return e;if(!e.eventsListeners)return e;let t,s,a;for(var i=arguments.length,r=new Array(i),n=0;n{e.eventsAnyListeners&&e.eventsAnyListeners.length&&e.eventsAnyListeners.forEach((e=>{e.apply(a,[t,...s])})),e.eventsListeners&&e.eventsListeners[t]&&e.eventsListeners[t].forEach((e=>{e.apply(a,s)}))})),e}};var S={updateSize:function(){const e=this;let t,s;const a=e.$el;t=void 0!==e.params.width&&null!==e.params.width?e.params.width:a[0].clientWidth,s=void 0!==e.params.height&&null!==e.params.height?e.params.height:a[0].clientHeight,0===t&&e.isHorizontal()||0===s&&e.isVertical()||(t=t-parseInt(a.css("padding-left")||0,10)-parseInt(a.css("padding-right")||0,10),s=s-parseInt(a.css("padding-top")||0,10)-parseInt(a.css("padding-bottom")||0,10),Number.isNaN(t)&&(t=0),Number.isNaN(s)&&(s=0),Object.assign(e,{width:t,height:s,size:e.isHorizontal()?t:s}))},updateSlides:function(){const e=this;function t(t){return e.isHorizontal()?t:{width:"height","margin-top":"margin-left","margin-bottom ":"margin-right","margin-left":"margin-top","margin-right":"margin-bottom","padding-left":"padding-top","padding-right":"padding-bottom",marginRight:"marginBottom"}[t]}function s(e,s){return parseFloat(e.getPropertyValue(t(s))||0)}const a=e.params,{$wrapperEl:i,size:r,rtlTranslate:n,wrongRTL:l}=e,o=e.virtual&&a.virtual.enabled,d=o?e.virtual.slides.length:e.slides.length,c=i.children(`.${e.params.slideClass}`),p=o?e.virtual.slides.length:c.length;let u=[];const h=[],m=[];let f=a.slidesOffsetBefore;"function"==typeof f&&(f=a.slidesOffsetBefore.call(e));let g=a.slidesOffsetAfter;"function"==typeof g&&(g=a.slidesOffsetAfter.call(e));const w=e.snapGrid.length,b=e.slidesGrid.length;let x=a.spaceBetween,y=-f,E=0,C=0;if(void 0===r)return;"string"==typeof x&&x.indexOf("%")>=0&&(x=parseFloat(x.replace("%",""))/100*r),e.virtualSize=-x,n?c.css({marginLeft:"",marginBottom:"",marginTop:""}):c.css({marginRight:"",marginBottom:"",marginTop:""}),a.centeredSlides&&a.cssMode&&(v(e.wrapperEl,"--swiper-centered-offset-before",""),v(e.wrapperEl,"--swiper-centered-offset-after",""));const T=a.grid&&a.grid.rows>1&&e.grid;let $;T&&e.grid.initSlides(p);const S="auto"===a.slidesPerView&&a.breakpoints&&Object.keys(a.breakpoints).filter((e=>void 0!==a.breakpoints[e].slidesPerView)).length>0;for(let i=0;i1&&u.push(e.virtualSize-r)}if(0===u.length&&(u=[0]),0!==a.spaceBetween){const s=e.isHorizontal()&&n?"marginLeft":t("marginRight");c.filter(((e,t)=>!a.cssMode||t!==c.length-1)).css({[s]:`${x}px`})}if(a.centeredSlides&&a.centeredSlidesBounds){let e=0;m.forEach((t=>{e+=t+(a.spaceBetween?a.spaceBetween:0)})),e-=a.spaceBetween;const t=e-r;u=u.map((e=>e<0?-f:e>t?t+g:e))}if(a.centerInsufficientSlides){let e=0;if(m.forEach((t=>{e+=t+(a.spaceBetween?a.spaceBetween:0)})),e-=a.spaceBetween,e{u[s]=e-t})),h.forEach(((e,s)=>{h[s]=e+t}))}}if(Object.assign(e,{slides:c,snapGrid:u,slidesGrid:h,slidesSizesGrid:m}),a.centeredSlides&&a.cssMode&&!a.centeredSlidesBounds){v(e.wrapperEl,"--swiper-centered-offset-before",-u[0]+"px"),v(e.wrapperEl,"--swiper-centered-offset-after",e.size/2-m[m.length-1]/2+"px");const t=-e.snapGrid[0],s=-e.slidesGrid[0];e.snapGrid=e.snapGrid.map((e=>e+t)),e.slidesGrid=e.slidesGrid.map((e=>e+s))}if(p!==d&&e.emit("slidesLengthChange"),u.length!==w&&(e.params.watchOverflow&&e.checkOverflow(),e.emit("snapGridLengthChange")),h.length!==b&&e.emit("slidesGridLengthChange"),a.watchSlidesProgress&&e.updateSlidesOffset(),!(o||a.cssMode||"slide"!==a.effect&&"fade"!==a.effect)){const t=`${a.containerModifierClass}backface-hidden`,s=e.$el.hasClass(t);p<=a.maxBackfaceHiddenSlides?s||e.$el.addClass(t):s&&e.$el.removeClass(t)}},updateAutoHeight:function(e){const t=this,s=[],a=t.virtual&&t.params.virtual.enabled;let i,r=0;"number"==typeof e?t.setTransition(e):!0===e&&t.setTransition(t.params.speed);const n=e=>a?t.slides.filter((t=>parseInt(t.getAttribute("data-swiper-slide-index"),10)===e))[0]:t.slides.eq(e)[0];if("auto"!==t.params.slidesPerView&&t.params.slidesPerView>1)if(t.params.centeredSlides)(t.visibleSlides||d([])).each((e=>{s.push(e)}));else for(i=0;it.slides.length&&!a)break;s.push(n(e))}else s.push(n(t.activeIndex));for(i=0;ir?e:r}(r||0===r)&&t.$wrapperEl.css("height",`${r}px`)},updateSlidesOffset:function(){const e=this,t=e.slides;for(let s=0;s=0&&p1&&u<=t.size||p<=0&&u>=t.size)&&(t.visibleSlides.push(l),t.visibleSlidesIndexes.push(e),a.eq(e).addClass(s.slideVisibleClass)),l.progress=i?-d:d,l.originalProgress=i?-c:c}t.visibleSlides=d(t.visibleSlides)},updateProgress:function(e){const t=this;if(void 0===e){const s=t.rtlTranslate?-1:1;e=t&&t.translate&&t.translate*s||0}const s=t.params,a=t.maxTranslate()-t.minTranslate();let{progress:i,isBeginning:r,isEnd:n}=t;const l=r,o=n;0===a?(i=0,r=!0,n=!0):(i=(e-t.minTranslate())/a,r=i<=0,n=i>=1),Object.assign(t,{progress:i,isBeginning:r,isEnd:n}),(s.watchSlidesProgress||s.centeredSlides&&s.autoHeight)&&t.updateSlidesProgress(e),r&&!l&&t.emit("reachBeginning toEdge"),n&&!o&&t.emit("reachEnd toEdge"),(l&&!r||o&&!n)&&t.emit("fromEdge"),t.emit("progress",i)},updateSlidesClasses:function(){const e=this,{slides:t,params:s,$wrapperEl:a,activeIndex:i,realIndex:r}=e,n=e.virtual&&s.virtual.enabled;let l;t.removeClass(`${s.slideActiveClass} ${s.slideNextClass} ${s.slidePrevClass} ${s.slideDuplicateActiveClass} ${s.slideDuplicateNextClass} ${s.slideDuplicatePrevClass}`),l=n?e.$wrapperEl.find(`.${s.slideClass}[data-swiper-slide-index="${i}"]`):t.eq(i),l.addClass(s.slideActiveClass),s.loop&&(l.hasClass(s.slideDuplicateClass)?a.children(`.${s.slideClass}:not(.${s.slideDuplicateClass})[data-swiper-slide-index="${r}"]`).addClass(s.slideDuplicateActiveClass):a.children(`.${s.slideClass}.${s.slideDuplicateClass}[data-swiper-slide-index="${r}"]`).addClass(s.slideDuplicateActiveClass));let o=l.nextAll(`.${s.slideClass}`).eq(0).addClass(s.slideNextClass);s.loop&&0===o.length&&(o=t.eq(0),o.addClass(s.slideNextClass));let d=l.prevAll(`.${s.slideClass}`).eq(0).addClass(s.slidePrevClass);s.loop&&0===d.length&&(d=t.eq(-1),d.addClass(s.slidePrevClass)),s.loop&&(o.hasClass(s.slideDuplicateClass)?a.children(`.${s.slideClass}:not(.${s.slideDuplicateClass})[data-swiper-slide-index="${o.attr("data-swiper-slide-index")}"]`).addClass(s.slideDuplicateNextClass):a.children(`.${s.slideClass}.${s.slideDuplicateClass}[data-swiper-slide-index="${o.attr("data-swiper-slide-index")}"]`).addClass(s.slideDuplicateNextClass),d.hasClass(s.slideDuplicateClass)?a.children(`.${s.slideClass}:not(.${s.slideDuplicateClass})[data-swiper-slide-index="${d.attr("data-swiper-slide-index")}"]`).addClass(s.slideDuplicatePrevClass):a.children(`.${s.slideClass}.${s.slideDuplicateClass}[data-swiper-slide-index="${d.attr("data-swiper-slide-index")}"]`).addClass(s.slideDuplicatePrevClass)),e.emitSlidesClasses()},updateActiveIndex:function(e){const t=this,s=t.rtlTranslate?t.translate:-t.translate,{slidesGrid:a,snapGrid:i,params:r,activeIndex:n,realIndex:l,snapIndex:o}=t;let d,c=e;if(void 0===c){for(let e=0;e=a[e]&&s=a[e]&&s=a[e]&&(c=e);r.normalizeSlideIndex&&(c<0||void 0===c)&&(c=0)}if(i.indexOf(s)>=0)d=i.indexOf(s);else{const e=Math.min(r.slidesPerGroupSkip,c);d=e+Math.floor((c-e)/r.slidesPerGroup)}if(d>=i.length&&(d=i.length-1),c===n)return void(d!==o&&(t.snapIndex=d,t.emit("snapIndexChange")));const p=parseInt(t.slides.eq(c).attr("data-swiper-slide-index")||c,10);Object.assign(t,{snapIndex:d,realIndex:p,previousIndex:n,activeIndex:c}),t.emit("activeIndexChange"),t.emit("snapIndexChange"),l!==p&&t.emit("realIndexChange"),(t.initialized||t.params.runCallbacksOnInit)&&t.emit("slideChange")},updateClickedSlide:function(e){const t=this,s=t.params,a=d(e).closest(`.${s.slideClass}`)[0];let i,r=!1;if(a)for(let e=0;eo?o:a&&en?"next":r=o.length&&(g=o.length-1);const v=-o[g];if(l.normalizeSlideIndex)for(let e=0;e=s&&t=s&&t=s&&(n=e)}if(r.initialized&&n!==p){if(!r.allowSlideNext&&vr.translate&&v>r.maxTranslate()&&(p||0)!==n)return!1}let b;if(n!==(c||0)&&s&&r.emit("beforeSlideChangeStart"),r.updateProgress(v),b=n>p?"next":n{r.wrapperEl.style.scrollSnapType="",r._swiperImmediateVirtual=!1}))}else{if(!r.support.smoothScroll)return w({swiper:r,targetPosition:s,side:e?"left":"top"}),!0;h.scrollTo({[e?"left":"top"]:s,behavior:"smooth"})}return!0}return r.setTransition(t),r.setTranslate(v),r.updateActiveIndex(n),r.updateSlidesClasses(),r.emit("beforeTransitionStart",t,a),r.transitionStart(s,b),0===t?r.transitionEnd(s,b):r.animating||(r.animating=!0,r.onSlideToWrapperTransitionEnd||(r.onSlideToWrapperTransitionEnd=function(e){r&&!r.destroyed&&e.target===this&&(r.$wrapperEl[0].removeEventListener("transitionend",r.onSlideToWrapperTransitionEnd),r.$wrapperEl[0].removeEventListener("webkitTransitionEnd",r.onSlideToWrapperTransitionEnd),r.onSlideToWrapperTransitionEnd=null,delete r.onSlideToWrapperTransitionEnd,r.transitionEnd(s,b))}),r.$wrapperEl[0].addEventListener("transitionend",r.onSlideToWrapperTransitionEnd),r.$wrapperEl[0].addEventListener("webkitTransitionEnd",r.onSlideToWrapperTransitionEnd)),!0},slideToLoop:function(e,t,s,a){if(void 0===e&&(e=0),void 0===t&&(t=this.params.speed),void 0===s&&(s=!0),"string"==typeof e){const t=parseInt(e,10);if(!isFinite(t))throw new Error(`The passed-in 'index' (string) couldn't be converted to 'number'. [${e}] given.`);e=t}const i=this;let r=e;return i.params.loop&&(r+=i.loopedSlides),i.slideTo(r,t,s,a)},slideNext:function(e,t,s){void 0===e&&(e=this.params.speed),void 0===t&&(t=!0);const a=this,{animating:i,enabled:r,params:n}=a;if(!r)return a;let l=n.slidesPerGroup;"auto"===n.slidesPerView&&1===n.slidesPerGroup&&n.slidesPerGroupAuto&&(l=Math.max(a.slidesPerViewDynamic("current",!0),1));const o=a.activeIndexc(e)));let h=n[u.indexOf(p)-1];if(void 0===h&&i.cssMode){let e;n.forEach(((t,s)=>{p>=t&&(e=s)})),void 0!==e&&(h=n[e>0?e-1:e])}let m=0;if(void 0!==h&&(m=l.indexOf(h),m<0&&(m=a.activeIndex-1),"auto"===i.slidesPerView&&1===i.slidesPerGroup&&i.slidesPerGroupAuto&&(m=m-a.slidesPerViewDynamic("previous",!0)+1,m=Math.max(m,0))),i.rewind&&a.isBeginning){const i=a.params.virtual&&a.params.virtual.enabled&&a.virtual?a.virtual.slides.length-1:a.slides.length-1;return a.slideTo(i,e,t,s)}return a.slideTo(m,e,t,s)},slideReset:function(e,t,s){return void 0===e&&(e=this.params.speed),void 0===t&&(t=!0),this.slideTo(this.activeIndex,e,t,s)},slideToClosest:function(e,t,s,a){void 0===e&&(e=this.params.speed),void 0===t&&(t=!0),void 0===a&&(a=.5);const i=this;let r=i.activeIndex;const n=Math.min(i.params.slidesPerGroupSkip,r),l=n+Math.floor((r-n)/i.params.slidesPerGroup),o=i.rtlTranslate?i.translate:-i.translate;if(o>=i.snapGrid[l]){const e=i.snapGrid[l];o-e>(i.snapGrid[l+1]-e)*a&&(r+=i.params.slidesPerGroup)}else{const e=i.snapGrid[l-1];o-e<=(i.snapGrid[l]-e)*a&&(r-=i.params.slidesPerGroup)}return r=Math.max(r,0),r=Math.min(r,i.slidesGrid.length-1),i.slideTo(r,e,t,s)},slideToClickedSlide:function(){const e=this,{params:t,$wrapperEl:s}=e,a="auto"===t.slidesPerView?e.slidesPerViewDynamic():t.slidesPerView;let i,r=e.clickedIndex;if(t.loop){if(e.animating)return;i=parseInt(d(e.clickedSlide).attr("data-swiper-slide-index"),10),t.centeredSlides?re.slides.length-e.loopedSlides+a/2?(e.loopFix(),r=s.children(`.${t.slideClass}[data-swiper-slide-index="${i}"]:not(.${t.slideDuplicateClass})`).eq(0).index(),p((()=>{e.slideTo(r)}))):e.slideTo(r):r>e.slides.length-a?(e.loopFix(),r=s.children(`.${t.slideClass}[data-swiper-slide-index="${i}"]:not(.${t.slideDuplicateClass})`).eq(0).index(),p((()=>{e.slideTo(r)}))):e.slideTo(r)}else e.slideTo(r)}};var z={loopCreate:function(){const e=this,t=a(),{params:s,$wrapperEl:i}=e,r=i.children().length>0?d(i.children()[0].parentNode):i;r.children(`.${s.slideClass}.${s.slideDuplicateClass}`).remove();let n=r.children(`.${s.slideClass}`);if(s.loopFillGroupWithBlank){const e=s.slidesPerGroup-n.length%s.slidesPerGroup;if(e!==s.slidesPerGroup){for(let a=0;an.length&&e.params.loopedSlidesLimit&&(e.loopedSlides=n.length);const l=[],o=[];n.each(((e,t)=>{d(e).attr("data-swiper-slide-index",t)}));for(let t=0;t=0;e-=1)r.prepend(d(l[e].cloneNode(!0)).addClass(s.slideDuplicateClass))},loopFix:function(){const e=this;e.emit("beforeLoopFix");const{activeIndex:t,slides:s,loopedSlides:a,allowSlidePrev:i,allowSlideNext:r,snapGrid:n,rtlTranslate:l}=e;let o;e.allowSlidePrev=!0,e.allowSlideNext=!0;const d=-n[t]-e.getTranslate();if(t=s.length-a){o=-s.length+t+a,o+=a;e.slideTo(o,0,!1,!0)&&0!==d&&e.setTranslate((l?-e.translate:e.translate)-d)}e.allowSlidePrev=i,e.allowSlideNext=r,e.emit("loopFix")},loopDestroy:function(){const{$wrapperEl:e,params:t,slides:s}=this;e.children(`.${t.slideClass}.${t.slideDuplicateClass},.${t.slideClass}.${t.slideBlankClass}`).remove(),s.removeAttr("data-swiper-slide-index")}};function L(e){const t=this,s=a(),i=r(),n=t.touchEventsData,{params:l,touches:o,enabled:c}=t;if(!c)return;if(t.animating&&l.preventInteractionOnTransition)return;!t.animating&&l.cssMode&&l.loop&&t.loopFix();let p=e;p.originalEvent&&(p=p.originalEvent);let h=d(p.target);if("wrapper"===l.touchEventsTarget&&!h.closest(t.wrapperEl).length)return;if(n.isTouchEvent="touchstart"===p.type,!n.isTouchEvent&&"which"in p&&3===p.which)return;if(!n.isTouchEvent&&"button"in p&&p.button>0)return;if(n.isTouched&&n.isMoved)return;const m=!!l.noSwipingClass&&""!==l.noSwipingClass,f=e.composedPath?e.composedPath():e.path;m&&p.target&&p.target.shadowRoot&&f&&(h=d(f[0]));const g=l.noSwipingSelector?l.noSwipingSelector:`.${l.noSwipingClass}`,v=!(!p.target||!p.target.shadowRoot);if(l.noSwiping&&(v?function(e,t){return void 0===t&&(t=this),function t(s){if(!s||s===a()||s===r())return null;s.assignedSlot&&(s=s.assignedSlot);const i=s.closest(e);return i||s.getRootNode?i||t(s.getRootNode().host):null}(t)}(g,h[0]):h.closest(g)[0]))return void(t.allowClick=!0);if(l.swipeHandler&&!h.closest(l.swipeHandler)[0])return;o.currentX="touchstart"===p.type?p.targetTouches[0].pageX:p.pageX,o.currentY="touchstart"===p.type?p.targetTouches[0].pageY:p.pageY;const w=o.currentX,b=o.currentY,x=l.edgeSwipeDetection||l.iOSEdgeSwipeDetection,y=l.edgeSwipeThreshold||l.iOSEdgeSwipeThreshold;if(x&&(w<=y||w>=i.innerWidth-y)){if("prevent"!==x)return;e.preventDefault()}if(Object.assign(n,{isTouched:!0,isMoved:!1,allowTouchCallbacks:!0,isScrolling:void 0,startMoving:void 0}),o.startX=w,o.startY=b,n.touchStartTime=u(),t.allowClick=!0,t.updateSize(),t.swipeDirection=void 0,l.threshold>0&&(n.allowThresholdMove=!1),"touchstart"!==p.type){let e=!0;h.is(n.focusableElements)&&(e=!1,"SELECT"===h[0].nodeName&&(n.isTouched=!1)),s.activeElement&&d(s.activeElement).is(n.focusableElements)&&s.activeElement!==h[0]&&s.activeElement.blur();const a=e&&t.allowTouchMove&&l.touchStartPreventDefault;!l.touchStartForcePreventDefault&&!a||h[0].isContentEditable||p.preventDefault()}t.params.freeMode&&t.params.freeMode.enabled&&t.freeMode&&t.animating&&!l.cssMode&&t.freeMode.onTouchStart(),t.emit("touchStart",p)}function O(e){const t=a(),s=this,i=s.touchEventsData,{params:r,touches:n,rtlTranslate:l,enabled:o}=s;if(!o)return;let c=e;if(c.originalEvent&&(c=c.originalEvent),!i.isTouched)return void(i.startMoving&&i.isScrolling&&s.emit("touchMoveOpposite",c));if(i.isTouchEvent&&"touchmove"!==c.type)return;const p="touchmove"===c.type&&c.targetTouches&&(c.targetTouches[0]||c.changedTouches[0]),h="touchmove"===c.type?p.pageX:c.pageX,m="touchmove"===c.type?p.pageY:c.pageY;if(c.preventedByNestedSwiper)return n.startX=h,void(n.startY=m);if(!s.allowTouchMove)return d(c.target).is(i.focusableElements)||(s.allowClick=!1),void(i.isTouched&&(Object.assign(n,{startX:h,startY:m,currentX:h,currentY:m}),i.touchStartTime=u()));if(i.isTouchEvent&&r.touchReleaseOnEdges&&!r.loop)if(s.isVertical()){if(mn.startY&&s.translate>=s.minTranslate())return i.isTouched=!1,void(i.isMoved=!1)}else if(hn.startX&&s.translate>=s.minTranslate())return;if(i.isTouchEvent&&t.activeElement&&c.target===t.activeElement&&d(c.target).is(i.focusableElements))return i.isMoved=!0,void(s.allowClick=!1);if(i.allowTouchCallbacks&&s.emit("touchMove",c),c.targetTouches&&c.targetTouches.length>1)return;n.currentX=h,n.currentY=m;const f=n.currentX-n.startX,g=n.currentY-n.startY;if(s.params.threshold&&Math.sqrt(f**2+g**2)=25&&(e=180*Math.atan2(Math.abs(g),Math.abs(f))/Math.PI,i.isScrolling=s.isHorizontal()?e>r.touchAngle:90-e>r.touchAngle)}if(i.isScrolling&&s.emit("touchMoveOpposite",c),void 0===i.startMoving&&(n.currentX===n.startX&&n.currentY===n.startY||(i.startMoving=!0)),i.isScrolling)return void(i.isTouched=!1);if(!i.startMoving)return;s.allowClick=!1,!r.cssMode&&c.cancelable&&c.preventDefault(),r.touchMoveStopPropagation&&!r.nested&&c.stopPropagation(),i.isMoved||(r.loop&&!r.cssMode&&s.loopFix(),i.startTranslate=s.getTranslate(),s.setTransition(0),s.animating&&s.$wrapperEl.trigger("webkitTransitionEnd transitionend"),i.allowMomentumBounce=!1,!r.grabCursor||!0!==s.allowSlideNext&&!0!==s.allowSlidePrev||s.setGrabCursor(!0),s.emit("sliderFirstMove",c)),s.emit("sliderMove",c),i.isMoved=!0;let v=s.isHorizontal()?f:g;n.diff=v,v*=r.touchRatio,l&&(v=-v),s.swipeDirection=v>0?"prev":"next",i.currentTranslate=v+i.startTranslate;let w=!0,b=r.resistanceRatio;if(r.touchReleaseOnEdges&&(b=0),v>0&&i.currentTranslate>s.minTranslate()?(w=!1,r.resistance&&(i.currentTranslate=s.minTranslate()-1+(-s.minTranslate()+i.startTranslate+v)**b)):v<0&&i.currentTranslatei.startTranslate&&(i.currentTranslate=i.startTranslate),s.allowSlidePrev||s.allowSlideNext||(i.currentTranslate=i.startTranslate),r.threshold>0){if(!(Math.abs(v)>r.threshold||i.allowThresholdMove))return void(i.currentTranslate=i.startTranslate);if(!i.allowThresholdMove)return i.allowThresholdMove=!0,n.startX=n.currentX,n.startY=n.currentY,i.currentTranslate=i.startTranslate,void(n.diff=s.isHorizontal()?n.currentX-n.startX:n.currentY-n.startY)}r.followFinger&&!r.cssMode&&((r.freeMode&&r.freeMode.enabled&&s.freeMode||r.watchSlidesProgress)&&(s.updateActiveIndex(),s.updateSlidesClasses()),s.params.freeMode&&r.freeMode.enabled&&s.freeMode&&s.freeMode.onTouchMove(),s.updateProgress(i.currentTranslate),s.setTranslate(i.currentTranslate))}function I(e){const t=this,s=t.touchEventsData,{params:a,touches:i,rtlTranslate:r,slidesGrid:n,enabled:l}=t;if(!l)return;let o=e;if(o.originalEvent&&(o=o.originalEvent),s.allowTouchCallbacks&&t.emit("touchEnd",o),s.allowTouchCallbacks=!1,!s.isTouched)return s.isMoved&&a.grabCursor&&t.setGrabCursor(!1),s.isMoved=!1,void(s.startMoving=!1);a.grabCursor&&s.isMoved&&s.isTouched&&(!0===t.allowSlideNext||!0===t.allowSlidePrev)&&t.setGrabCursor(!1);const d=u(),c=d-s.touchStartTime;if(t.allowClick){const e=o.path||o.composedPath&&o.composedPath();t.updateClickedSlide(e&&e[0]||o.target),t.emit("tap click",o),c<300&&d-s.lastClickTime<300&&t.emit("doubleTap doubleClick",o)}if(s.lastClickTime=u(),p((()=>{t.destroyed||(t.allowClick=!0)})),!s.isTouched||!s.isMoved||!t.swipeDirection||0===i.diff||s.currentTranslate===s.startTranslate)return s.isTouched=!1,s.isMoved=!1,void(s.startMoving=!1);let h;if(s.isTouched=!1,s.isMoved=!1,s.startMoving=!1,h=a.followFinger?r?t.translate:-t.translate:-s.currentTranslate,a.cssMode)return;if(t.params.freeMode&&a.freeMode.enabled)return void t.freeMode.onTouchEnd({currentPos:h});let m=0,f=t.slidesSizesGrid[0];for(let e=0;e=n[e]&&h=n[e]&&(m=e,f=n[n.length-1]-n[n.length-2])}let g=null,v=null;a.rewind&&(t.isBeginning?v=t.params.virtual&&t.params.virtual.enabled&&t.virtual?t.virtual.slides.length-1:t.slides.length-1:t.isEnd&&(g=0));const w=(h-n[m])/f,b=ma.longSwipesMs){if(!a.longSwipes)return void t.slideTo(t.activeIndex);"next"===t.swipeDirection&&(w>=a.longSwipesRatio?t.slideTo(a.rewind&&t.isEnd?g:m+b):t.slideTo(m)),"prev"===t.swipeDirection&&(w>1-a.longSwipesRatio?t.slideTo(m+b):null!==v&&w<0&&Math.abs(w)>a.longSwipesRatio?t.slideTo(v):t.slideTo(m))}else{if(!a.shortSwipes)return void t.slideTo(t.activeIndex);t.navigation&&(o.target===t.navigation.nextEl||o.target===t.navigation.prevEl)?o.target===t.navigation.nextEl?t.slideTo(m+b):t.slideTo(m):("next"===t.swipeDirection&&t.slideTo(null!==g?g:m+b),"prev"===t.swipeDirection&&t.slideTo(null!==v?v:m))}}function A(){const e=this,{params:t,el:s}=e;if(s&&0===s.offsetWidth)return;t.breakpoints&&e.setBreakpoint();const{allowSlideNext:a,allowSlidePrev:i,snapGrid:r}=e;e.allowSlideNext=!0,e.allowSlidePrev=!0,e.updateSize(),e.updateSlides(),e.updateSlidesClasses(),("auto"===t.slidesPerView||t.slidesPerView>1)&&e.isEnd&&!e.isBeginning&&!e.params.centeredSlides?e.slideTo(e.slides.length-1,0,!1,!0):e.slideTo(e.activeIndex,0,!1,!0),e.autoplay&&e.autoplay.running&&e.autoplay.paused&&e.autoplay.run(),e.allowSlidePrev=i,e.allowSlideNext=a,e.params.watchOverflow&&r!==e.snapGrid&&e.checkOverflow()}function D(e){const t=this;t.enabled&&(t.allowClick||(t.params.preventClicks&&e.preventDefault(),t.params.preventClicksPropagation&&t.animating&&(e.stopPropagation(),e.stopImmediatePropagation())))}function G(){const e=this,{wrapperEl:t,rtlTranslate:s,enabled:a}=e;if(!a)return;let i;e.previousTranslate=e.translate,e.isHorizontal()?e.translate=-t.scrollLeft:e.translate=-t.scrollTop,0===e.translate&&(e.translate=0),e.updateActiveIndex(),e.updateSlidesClasses();const r=e.maxTranslate()-e.minTranslate();i=0===r?0:(e.translate-e.minTranslate())/r,i!==e.progress&&e.updateProgress(s?-e.translate:e.translate),e.emit("setTranslate",e.translate,!1)}let N=!1;function B(){}const H=(e,t)=>{const s=a(),{params:i,touchEvents:r,el:n,wrapperEl:l,device:o,support:d}=e,c=!!i.nested,p="on"===t?"addEventListener":"removeEventListener",u=t;if(d.touch){const t=!("touchstart"!==r.start||!d.passiveListener||!i.passiveListeners)&&{passive:!0,capture:!1};n[p](r.start,e.onTouchStart,t),n[p](r.move,e.onTouchMove,d.passiveListener?{passive:!1,capture:c}:c),n[p](r.end,e.onTouchEnd,t),r.cancel&&n[p](r.cancel,e.onTouchEnd,t)}else n[p](r.start,e.onTouchStart,!1),s[p](r.move,e.onTouchMove,c),s[p](r.end,e.onTouchEnd,!1);(i.preventClicks||i.preventClicksPropagation)&&n[p]("click",e.onClick,!0),i.cssMode&&l[p]("scroll",e.onScroll),i.updateOnWindowResize?e[u](o.ios||o.android?"resize orientationchange observerUpdate":"resize observerUpdate",A,!0):e[u]("observerUpdate",A,!0)};var X={attachEvents:function(){const e=this,t=a(),{params:s,support:i}=e;e.onTouchStart=L.bind(e),e.onTouchMove=O.bind(e),e.onTouchEnd=I.bind(e),s.cssMode&&(e.onScroll=G.bind(e)),e.onClick=D.bind(e),i.touch&&!N&&(t.addEventListener("touchstart",B),N=!0),H(e,"on")},detachEvents:function(){H(this,"off")}};const Y=(e,t)=>e.grid&&t.grid&&t.grid.rows>1;var R={addClasses:function(){const e=this,{classNames:t,params:s,rtl:a,$el:i,device:r,support:n}=e,l=function(e,t){const s=[];return e.forEach((e=>{"object"==typeof e?Object.keys(e).forEach((a=>{e[a]&&s.push(t+a)})):"string"==typeof e&&s.push(t+e)})),s}(["initialized",s.direction,{"pointer-events":!n.touch},{"free-mode":e.params.freeMode&&s.freeMode.enabled},{autoheight:s.autoHeight},{rtl:a},{grid:s.grid&&s.grid.rows>1},{"grid-column":s.grid&&s.grid.rows>1&&"column"===s.grid.fill},{android:r.android},{ios:r.ios},{"css-mode":s.cssMode},{centered:s.cssMode&&s.centeredSlides},{"watch-progress":s.watchSlidesProgress}],s.containerModifierClass);t.push(...l),i.addClass([...t].join(" ")),e.emitContainerClasses()},removeClasses:function(){const{$el:e,classNames:t}=this;e.removeClass(t.join(" ")),this.emitContainerClasses()}};var W={init:!0,direction:"horizontal",touchEventsTarget:"wrapper",initialSlide:0,speed:300,cssMode:!1,updateOnWindowResize:!0,resizeObserver:!0,nested:!1,createElements:!1,enabled:!0,focusableElements:"input, select, option, textarea, button, video, label",width:null,height:null,preventInteractionOnTransition:!1,userAgent:null,url:null,edgeSwipeDetection:!1,edgeSwipeThreshold:20,autoHeight:!1,setWrapperSize:!1,virtualTranslate:!1,effect:"slide",breakpoints:void 0,breakpointsBase:"window",spaceBetween:0,slidesPerView:1,slidesPerGroup:1,slidesPerGroupSkip:0,slidesPerGroupAuto:!1,centeredSlides:!1,centeredSlidesBounds:!1,slidesOffsetBefore:0,slidesOffsetAfter:0,normalizeSlideIndex:!0,centerInsufficientSlides:!1,watchOverflow:!0,roundLengths:!1,touchRatio:1,touchAngle:45,simulateTouch:!0,shortSwipes:!0,longSwipes:!0,longSwipesRatio:.5,longSwipesMs:300,followFinger:!0,allowTouchMove:!0,threshold:0,touchMoveStopPropagation:!1,touchStartPreventDefault:!0,touchStartForcePreventDefault:!1,touchReleaseOnEdges:!1,uniqueNavElements:!0,resistance:!0,resistanceRatio:.85,watchSlidesProgress:!1,grabCursor:!1,preventClicks:!0,preventClicksPropagation:!0,slideToClickedSlide:!1,preloadImages:!0,updateOnImagesReady:!0,loop:!1,loopAdditionalSlides:0,loopedSlides:null,loopedSlidesLimit:!0,loopFillGroupWithBlank:!1,loopPreventsSlide:!0,rewind:!1,allowSlidePrev:!0,allowSlideNext:!0,swipeHandler:null,noSwiping:!0,noSwipingClass:"swiper-no-swiping",noSwipingSelector:null,passiveListeners:!0,maxBackfaceHiddenSlides:10,containerModifierClass:"swiper-",slideClass:"swiper-slide",slideBlankClass:"swiper-slide-invisible-blank",slideActiveClass:"swiper-slide-active",slideDuplicateActiveClass:"swiper-slide-duplicate-active",slideVisibleClass:"swiper-slide-visible",slideDuplicateClass:"swiper-slide-duplicate",slideNextClass:"swiper-slide-next",slideDuplicateNextClass:"swiper-slide-duplicate-next",slidePrevClass:"swiper-slide-prev",slideDuplicatePrevClass:"swiper-slide-duplicate-prev",wrapperClass:"swiper-wrapper",runCallbacksOnInit:!0,_emitClasses:!1};function q(e,t){return function(s){void 0===s&&(s={});const a=Object.keys(s)[0],i=s[a];"object"==typeof i&&null!==i?(["navigation","pagination","scrollbar"].indexOf(a)>=0&&!0===e[a]&&(e[a]={auto:!0}),a in e&&"enabled"in i?(!0===e[a]&&(e[a]={enabled:!0}),"object"!=typeof e[a]||"enabled"in e[a]||(e[a].enabled=!0),e[a]||(e[a]={enabled:!1}),g(t,s)):g(t,s)):g(t,s)}}const j={eventsEmitter:$,update:S,translate:M,transition:{setTransition:function(e,t){const s=this;s.params.cssMode||s.$wrapperEl.transition(e),s.emit("setTransition",e,t)},transitionStart:function(e,t){void 0===e&&(e=!0);const s=this,{params:a}=s;a.cssMode||(a.autoHeight&&s.updateAutoHeight(),P({swiper:s,runCallbacks:e,direction:t,step:"Start"}))},transitionEnd:function(e,t){void 0===e&&(e=!0);const s=this,{params:a}=s;s.animating=!1,a.cssMode||(s.setTransition(0),P({swiper:s,runCallbacks:e,direction:t,step:"End"}))}},slide:k,loop:z,grabCursor:{setGrabCursor:function(e){const t=this;if(t.support.touch||!t.params.simulateTouch||t.params.watchOverflow&&t.isLocked||t.params.cssMode)return;const s="container"===t.params.touchEventsTarget?t.el:t.wrapperEl;s.style.cursor="move",s.style.cursor=e?"grabbing":"grab"},unsetGrabCursor:function(){const e=this;e.support.touch||e.params.watchOverflow&&e.isLocked||e.params.cssMode||(e["container"===e.params.touchEventsTarget?"el":"wrapperEl"].style.cursor="")}},events:X,breakpoints:{setBreakpoint:function(){const e=this,{activeIndex:t,initialized:s,loopedSlides:a=0,params:i,$el:r}=e,n=i.breakpoints;if(!n||n&&0===Object.keys(n).length)return;const l=e.getBreakpoint(n,e.params.breakpointsBase,e.el);if(!l||e.currentBreakpoint===l)return;const o=(l in n?n[l]:void 0)||e.originalParams,d=Y(e,i),c=Y(e,o),p=i.enabled;d&&!c?(r.removeClass(`${i.containerModifierClass}grid ${i.containerModifierClass}grid-column`),e.emitContainerClasses()):!d&&c&&(r.addClass(`${i.containerModifierClass}grid`),(o.grid.fill&&"column"===o.grid.fill||!o.grid.fill&&"column"===i.grid.fill)&&r.addClass(`${i.containerModifierClass}grid-column`),e.emitContainerClasses()),["navigation","pagination","scrollbar"].forEach((t=>{const s=i[t]&&i[t].enabled,a=o[t]&&o[t].enabled;s&&!a&&e[t].disable(),!s&&a&&e[t].enable()}));const u=o.direction&&o.direction!==i.direction,h=i.loop&&(o.slidesPerView!==i.slidesPerView||u);u&&s&&e.changeDirection(),g(e.params,o);const m=e.params.enabled;Object.assign(e,{allowTouchMove:e.params.allowTouchMove,allowSlideNext:e.params.allowSlideNext,allowSlidePrev:e.params.allowSlidePrev}),p&&!m?e.disable():!p&&m&&e.enable(),e.currentBreakpoint=l,e.emit("_beforeBreakpoint",o),h&&s&&(e.loopDestroy(),e.loopCreate(),e.updateSlides(),e.slideTo(t-a+e.loopedSlides,0,!1)),e.emit("breakpoint",o)},getBreakpoint:function(e,t,s){if(void 0===t&&(t="window"),!e||"container"===t&&!s)return;let a=!1;const i=r(),n="window"===t?i.innerHeight:s.clientHeight,l=Object.keys(e).map((e=>{if("string"==typeof e&&0===e.indexOf("@")){const t=parseFloat(e.substr(1));return{value:n*t,point:e}}return{value:e,point:e}}));l.sort(((e,t)=>parseInt(e.value,10)-parseInt(t.value,10)));for(let e=0;es}else e.isLocked=1===e.snapGrid.length;!0===s.allowSlideNext&&(e.allowSlideNext=!e.isLocked),!0===s.allowSlidePrev&&(e.allowSlidePrev=!e.isLocked),t&&t!==e.isLocked&&(e.isEnd=!1),t!==e.isLocked&&e.emit(e.isLocked?"lock":"unlock")}},classes:R,images:{loadImage:function(e,t,s,a,i,n){const l=r();let o;function c(){n&&n()}d(e).parent("picture")[0]||e.complete&&i?c():t?(o=new l.Image,o.onload=c,o.onerror=c,a&&(o.sizes=a),s&&(o.srcset=s),t&&(o.src=t)):c()},preloadImages:function(){const e=this;function t(){null!=e&&e&&!e.destroyed&&(void 0!==e.imagesLoaded&&(e.imagesLoaded+=1),e.imagesLoaded===e.imagesToLoad.length&&(e.params.updateOnImagesReady&&e.update(),e.emit("imagesReady")))}e.imagesToLoad=e.$el.find("img");for(let s=0;s1){const e=[];return d(t.el).each((s=>{const a=g({},t,{el:s});e.push(new V(a))})),e}const r=this;r.__swiper__=!0,r.support=E(),r.device=C({userAgent:t.userAgent}),r.browser=T(),r.eventsListeners={},r.eventsAnyListeners=[],r.modules=[...r.__modules__],t.modules&&Array.isArray(t.modules)&&r.modules.push(...t.modules);const n={};r.modules.forEach((e=>{e({swiper:r,extendParams:q(t,n),on:r.on.bind(r),once:r.once.bind(r),off:r.off.bind(r),emit:r.emit.bind(r)})}));const l=g({},W,n);return r.params=g({},l,_,t),r.originalParams=g({},r.params),r.passedParams=g({},t),r.params&&r.params.on&&Object.keys(r.params.on).forEach((e=>{r.on(e,r.params.on[e])})),r.params&&r.params.onAny&&r.onAny(r.params.onAny),r.$=d,Object.assign(r,{enabled:r.params.enabled,el:e,classNames:[],slides:d(),slidesGrid:[],snapGrid:[],slidesSizesGrid:[],isHorizontal:()=>"horizontal"===r.params.direction,isVertical:()=>"vertical"===r.params.direction,activeIndex:0,realIndex:0,isBeginning:!0,isEnd:!1,translate:0,previousTranslate:0,progress:0,velocity:0,animating:!1,allowSlideNext:r.params.allowSlideNext,allowSlidePrev:r.params.allowSlidePrev,touchEvents:function(){const e=["touchstart","touchmove","touchend","touchcancel"],t=["pointerdown","pointermove","pointerup"];return r.touchEventsTouch={start:e[0],move:e[1],end:e[2],cancel:e[3]},r.touchEventsDesktop={start:t[0],move:t[1],end:t[2]},r.support.touch||!r.params.simulateTouch?r.touchEventsTouch:r.touchEventsDesktop}(),touchEventsData:{isTouched:void 0,isMoved:void 0,allowTouchCallbacks:void 0,touchStartTime:void 0,isScrolling:void 0,currentTranslate:void 0,startTranslate:void 0,allowThresholdMove:void 0,focusableElements:r.params.focusableElements,lastClickTime:u(),clickTimeout:void 0,velocities:[],allowMomentumBounce:void 0,isTouchEvent:void 0,startMoving:void 0},allowClick:!0,allowTouchMove:r.params.allowTouchMove,touches:{startX:0,startY:0,currentX:0,currentY:0,diff:0},imagesToLoad:[],imagesLoaded:0}),r.emit("_swiper"),r.params.init&&r.init(),r}enable(){const e=this;e.enabled||(e.enabled=!0,e.params.grabCursor&&e.setGrabCursor(),e.emit("enable"))}disable(){const e=this;e.enabled&&(e.enabled=!1,e.params.grabCursor&&e.unsetGrabCursor(),e.emit("disable"))}setProgress(e,t){const s=this;e=Math.min(Math.max(e,0),1);const a=s.minTranslate(),i=(s.maxTranslate()-a)*e+a;s.translateTo(i,void 0===t?0:t),s.updateActiveIndex(),s.updateSlidesClasses()}emitContainerClasses(){const e=this;if(!e.params._emitClasses||!e.el)return;const t=e.el.className.split(" ").filter((t=>0===t.indexOf("swiper")||0===t.indexOf(e.params.containerModifierClass)));e.emit("_containerClasses",t.join(" "))}getSlideClasses(e){const t=this;return t.destroyed?"":e.className.split(" ").filter((e=>0===e.indexOf("swiper-slide")||0===e.indexOf(t.params.slideClass))).join(" ")}emitSlidesClasses(){const e=this;if(!e.params._emitClasses||!e.el)return;const t=[];e.slides.each((s=>{const a=e.getSlideClasses(s);t.push({slideEl:s,classNames:a}),e.emit("_slideClass",s,a)})),e.emit("_slideClasses",t)}slidesPerViewDynamic(e,t){void 0===e&&(e="current"),void 0===t&&(t=!1);const{params:s,slides:a,slidesGrid:i,slidesSizesGrid:r,size:n,activeIndex:l}=this;let o=1;if(s.centeredSlides){let e,t=a[l].swiperSlideSize;for(let s=l+1;sn&&(e=!0));for(let s=l-1;s>=0;s-=1)a[s]&&!e&&(t+=a[s].swiperSlideSize,o+=1,t>n&&(e=!0))}else if("current"===e)for(let e=l+1;e=0;e-=1){i[l]-i[e]1)&&e.isEnd&&!e.params.centeredSlides?e.slideTo(e.slides.length-1,0,!1,!0):e.slideTo(e.activeIndex,0,!1,!0),i||a()),s.watchOverflow&&t!==e.snapGrid&&e.checkOverflow(),e.emit("update")}changeDirection(e,t){void 0===t&&(t=!0);const s=this,a=s.params.direction;return e||(e="horizontal"===a?"vertical":"horizontal"),e===a||"horizontal"!==e&&"vertical"!==e||(s.$el.removeClass(`${s.params.containerModifierClass}${a}`).addClass(`${s.params.containerModifierClass}${e}`),s.emitContainerClasses(),s.params.direction=e,s.slides.each((t=>{"vertical"===e?t.style.width="":t.style.height=""})),s.emit("changeDirection"),t&&s.update()),s}changeLanguageDirection(e){const t=this;t.rtl&&"rtl"===e||!t.rtl&&"ltr"===e||(t.rtl="rtl"===e,t.rtlTranslate="horizontal"===t.params.direction&&t.rtl,t.rtl?(t.$el.addClass(`${t.params.containerModifierClass}rtl`),t.el.dir="rtl"):(t.$el.removeClass(`${t.params.containerModifierClass}rtl`),t.el.dir="ltr"),t.update())}mount(e){const t=this;if(t.mounted)return!0;const s=d(e||t.params.el);if(!(e=s[0]))return!1;e.swiper=t;const i=()=>`.${(t.params.wrapperClass||"").trim().split(" ").join(".")}`;let r=(()=>{if(e&&e.shadowRoot&&e.shadowRoot.querySelector){const t=d(e.shadowRoot.querySelector(i()));return t.children=e=>s.children(e),t}return s.children?s.children(i()):d(s).children(i())})();if(0===r.length&&t.params.createElements){const e=a().createElement("div");r=d(e),e.className=t.params.wrapperClass,s.append(e),s.children(`.${t.params.slideClass}`).each((e=>{r.append(e)}))}return Object.assign(t,{$el:s,el:e,$wrapperEl:r,wrapperEl:r[0],mounted:!0,rtl:"rtl"===e.dir.toLowerCase()||"rtl"===s.css("direction"),rtlTranslate:"horizontal"===t.params.direction&&("rtl"===e.dir.toLowerCase()||"rtl"===s.css("direction")),wrongRTL:"-webkit-box"===r.css("display")}),!0}init(e){const t=this;if(t.initialized)return t;return!1===t.mount(e)||(t.emit("beforeInit"),t.params.breakpoints&&t.setBreakpoint(),t.addClasses(),t.params.loop&&t.loopCreate(),t.updateSize(),t.updateSlides(),t.params.watchOverflow&&t.checkOverflow(),t.params.grabCursor&&t.enabled&&t.setGrabCursor(),t.params.preloadImages&&t.preloadImages(),t.params.loop?t.slideTo(t.params.initialSlide+t.loopedSlides,0,t.params.runCallbacksOnInit,!1,!0):t.slideTo(t.params.initialSlide,0,t.params.runCallbacksOnInit,!1,!0),t.attachEvents(),t.initialized=!0,t.emit("init"),t.emit("afterInit")),t}destroy(e,t){void 0===e&&(e=!0),void 0===t&&(t=!0);const s=this,{params:a,$el:i,$wrapperEl:r,slides:n}=s;return void 0===s.params||s.destroyed||(s.emit("beforeDestroy"),s.initialized=!1,s.detachEvents(),a.loop&&s.loopDestroy(),t&&(s.removeClasses(),i.removeAttr("style"),r.removeAttr("style"),n&&n.length&&n.removeClass([a.slideVisibleClass,a.slideActiveClass,a.slideNextClass,a.slidePrevClass].join(" ")).removeAttr("style").removeAttr("data-swiper-slide-index")),s.emit("destroy"),Object.keys(s.eventsListeners).forEach((e=>{s.off(e)})),!1!==e&&(s.$el[0].swiper=null,function(e){const t=e;Object.keys(t).forEach((e=>{try{t[e]=null}catch(e){}try{delete t[e]}catch(e){}}))}(s)),s.destroyed=!0),null}static extendDefaults(e){g(_,e)}static get extendedDefaults(){return _}static get defaults(){return W}static installModule(e){V.prototype.__modules__||(V.prototype.__modules__=[]);const t=V.prototype.__modules__;"function"==typeof e&&t.indexOf(e)<0&&t.push(e)}static use(e){return Array.isArray(e)?(e.forEach((e=>V.installModule(e))),V):(V.installModule(e),V)}}function F(e,t,s,i){const r=a();return e.params.createElements&&Object.keys(i).forEach((a=>{if(!s[a]&&!0===s.auto){let n=e.$el.children(`.${i[a]}`)[0];n||(n=r.createElement("div"),n.className=i[a],e.$el.append(n)),s[a]=n,t[a]=n}})),s}function U(e){return void 0===e&&(e=""),`.${e.trim().replace(/([\.:!\/])/g,"\\$1").replace(/ /g,".")}`}function K(e){const t=this,{$wrapperEl:s,params:a}=t;if(a.loop&&t.loopDestroy(),"object"==typeof e&&"length"in e)for(let t=0;t=l)return void s.appendSlide(t);let o=n>e?n+1:n;const d=[];for(let t=l-1;t>=e;t-=1){const e=s.slides.eq(t);e.remove(),d.unshift(e)}if("object"==typeof t&&"length"in t){for(let e=0;ee?n+t.length:n}else a.append(t);for(let e=0;e{if(s.params.effect!==t)return;s.classNames.push(`${s.params.containerModifierClass}${t}`),l&&l()&&s.classNames.push(`${s.params.containerModifierClass}3d`);const e=n?n():{};Object.assign(s.params,e),Object.assign(s.originalParams,e)})),a("setTranslate",(()=>{s.params.effect===t&&i()})),a("setTransition",((e,a)=>{s.params.effect===t&&r(a)})),a("transitionEnd",(()=>{if(s.params.effect===t&&o){if(!d||!d().slideShadows)return;s.slides.each((e=>{s.$(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").remove()})),o()}})),a("virtualUpdate",(()=>{s.params.effect===t&&(s.slides.length||(c=!0),requestAnimationFrame((()=>{c&&s.slides&&s.slides.length&&(i(),c=!1)})))}))}function se(e,t){return e.transformEl?t.find(e.transformEl).css({"backface-visibility":"hidden","-webkit-backface-visibility":"hidden"}):t}function ae(e){let{swiper:t,duration:s,transformEl:a,allSlides:i}=e;const{slides:r,activeIndex:n,$wrapperEl:l}=t;if(t.params.virtualTranslate&&0!==s){let e,s=!1;e=i?a?r.find(a):r:a?r.eq(n).find(a):r.eq(n),e.transitionEnd((()=>{if(s)return;if(!t||t.destroyed)return;s=!0,t.animating=!1;const e=["webkitTransitionEnd","transitionend"];for(let t=0;t`),i.append(r)),r}Object.keys(j).forEach((e=>{Object.keys(j[e]).forEach((t=>{V.prototype[t]=j[e][t]}))})),V.use([function(e){let{swiper:t,on:s,emit:a}=e;const i=r();let n=null,l=null;const o=()=>{t&&!t.destroyed&&t.initialized&&(a("beforeResize"),a("resize"))},d=()=>{t&&!t.destroyed&&t.initialized&&a("orientationchange")};s("init",(()=>{t.params.resizeObserver&&void 0!==i.ResizeObserver?t&&!t.destroyed&&t.initialized&&(n=new ResizeObserver((e=>{l=i.requestAnimationFrame((()=>{const{width:s,height:a}=t;let i=s,r=a;e.forEach((e=>{let{contentBoxSize:s,contentRect:a,target:n}=e;n&&n!==t.el||(i=a?a.width:(s[0]||s).inlineSize,r=a?a.height:(s[0]||s).blockSize)})),i===s&&r===a||o()}))})),n.observe(t.el)):(i.addEventListener("resize",o),i.addEventListener("orientationchange",d))})),s("destroy",(()=>{l&&i.cancelAnimationFrame(l),n&&n.unobserve&&t.el&&(n.unobserve(t.el),n=null),i.removeEventListener("resize",o),i.removeEventListener("orientationchange",d)}))},function(e){let{swiper:t,extendParams:s,on:a,emit:i}=e;const n=[],l=r(),o=function(e,t){void 0===t&&(t={});const s=new(l.MutationObserver||l.WebkitMutationObserver)((e=>{if(1===e.length)return void i("observerUpdate",e[0]);const t=function(){i("observerUpdate",e[0])};l.requestAnimationFrame?l.requestAnimationFrame(t):l.setTimeout(t,0)}));s.observe(e,{attributes:void 0===t.attributes||t.attributes,childList:void 0===t.childList||t.childList,characterData:void 0===t.characterData||t.characterData}),n.push(s)};s({observer:!1,observeParents:!1,observeSlideChildren:!1}),a("init",(()=>{if(t.params.observer){if(t.params.observeParents){const e=t.$el.parents();for(let t=0;t{n.forEach((e=>{e.disconnect()})),n.splice(0,n.length)}))}]);const re=[function(e){let t,{swiper:s,extendParams:a,on:i,emit:r}=e;function n(e,t){const a=s.params.virtual;if(a.cache&&s.virtual.cache[t])return s.virtual.cache[t];const i=a.renderSlide?d(a.renderSlide.call(s,e,t)):d(`

${e}
`);return i.attr("data-swiper-slide-index")||i.attr("data-swiper-slide-index",t),a.cache&&(s.virtual.cache[t]=i),i}function l(e){const{slidesPerView:t,slidesPerGroup:a,centeredSlides:i}=s.params,{addSlidesBefore:l,addSlidesAfter:o}=s.params.virtual,{from:d,to:c,slides:p,slidesGrid:u,offset:h}=s.virtual;s.params.cssMode||s.updateActiveIndex();const m=s.activeIndex||0;let f,g,v;f=s.rtlTranslate?"right":s.isHorizontal()?"left":"top",i?(g=Math.floor(t/2)+a+o,v=Math.floor(t/2)+a+l):(g=t+(a-1)+o,v=a+l);const w=Math.max((m||0)-v,0),b=Math.min((m||0)+g,p.length-1),x=(s.slidesGrid[w]||0)-(s.slidesGrid[0]||0);function y(){s.updateSlides(),s.updateProgress(),s.updateSlidesClasses(),s.lazy&&s.params.lazy.enabled&&s.lazy.load(),r("virtualUpdate")}if(Object.assign(s.virtual,{from:w,to:b,offset:x,slidesGrid:s.slidesGrid}),d===w&&c===b&&!e)return s.slidesGrid!==u&&x!==h&&s.slides.css(f,`${x}px`),s.updateProgress(),void r("virtualUpdate");if(s.params.virtual.renderExternal)return s.params.virtual.renderExternal.call(s,{offset:x,from:w,to:b,slides:function(){const e=[];for(let t=w;t<=b;t+=1)e.push(p[t]);return e}()}),void(s.params.virtual.renderExternalUpdate?y():r("virtualUpdate"));const E=[],C=[];if(e)s.$wrapperEl.find(`.${s.params.slideClass}`).remove();else for(let e=d;e<=c;e+=1)(eb)&&s.$wrapperEl.find(`.${s.params.slideClass}[data-swiper-slide-index="${e}"]`).remove();for(let t=0;t=w&&t<=b&&(void 0===c||e?C.push(t):(t>c&&C.push(t),t{s.$wrapperEl.append(n(p[e],e))})),E.sort(((e,t)=>t-e)).forEach((e=>{s.$wrapperEl.prepend(n(p[e],e))})),s.$wrapperEl.children(".swiper-slide").css(f,`${x}px`),y()}a({virtual:{enabled:!1,slides:[],cache:!0,renderSlide:null,renderExternal:null,renderExternalUpdate:!0,addSlidesBefore:0,addSlidesAfter:0}}),s.virtual={cache:{},from:void 0,to:void 0,slides:[],offset:0,slidesGrid:[]},i("beforeInit",(()=>{s.params.virtual.enabled&&(s.virtual.slides=s.params.virtual.slides,s.classNames.push(`${s.params.containerModifierClass}virtual`),s.params.watchSlidesProgress=!0,s.originalParams.watchSlidesProgress=!0,s.params.initialSlide||l())})),i("setTranslate",(()=>{s.params.virtual.enabled&&(s.params.cssMode&&!s._immediateVirtual?(clearTimeout(t),t=setTimeout((()=>{l()}),100)):l())})),i("init update resize",(()=>{s.params.virtual.enabled&&s.params.cssMode&&v(s.wrapperEl,"--swiper-virtual-size",`${s.virtualSize}px`)})),Object.assign(s.virtual,{appendSlide:function(e){if("object"==typeof e&&"length"in e)for(let t=0;t{const a=e[s],r=a.attr("data-swiper-slide-index");r&&a.attr("data-swiper-slide-index",parseInt(r,10)+i),t[parseInt(s,10)+i]=a})),s.virtual.cache=t}l(!0),s.slideTo(a,0)},removeSlide:function(e){if(null==e)return;let t=s.activeIndex;if(Array.isArray(e))for(let a=e.length-1;a>=0;a-=1)s.virtual.slides.splice(e[a],1),s.params.virtual.cache&&delete s.virtual.cache[e[a]],e[a]0&&0===t.$el.parents(`.${t.params.slideActiveClass}`).length)return;const a=t.$el,i=a[0].clientWidth,r=a[0].clientHeight,n=o.innerWidth,l=o.innerHeight,d=t.$el.offset();s&&(d.left-=t.$el[0].scrollLeft);const c=[[d.left,d.top],[d.left+i,d.top],[d.left,d.top+r],[d.left+i,d.top+r]];for(let t=0;t=0&&s[0]<=n&&s[1]>=0&&s[1]<=l){if(0===s[0]&&0===s[1])continue;e=!0}}if(!e)return}t.isHorizontal()?((d||c||p||u)&&(a.preventDefault?a.preventDefault():a.returnValue=!1),((c||u)&&!s||(d||p)&&s)&&t.slideNext(),((d||p)&&!s||(c||u)&&s)&&t.slidePrev()):((d||c||h||m)&&(a.preventDefault?a.preventDefault():a.returnValue=!1),(c||m)&&t.slideNext(),(d||h)&&t.slidePrev()),n("keyPress",i)}}function p(){t.keyboard.enabled||(d(l).on("keydown",c),t.keyboard.enabled=!0)}function u(){t.keyboard.enabled&&(d(l).off("keydown",c),t.keyboard.enabled=!1)}t.keyboard={enabled:!1},s({keyboard:{enabled:!1,onlyInViewport:!0,pageUpDown:!0}}),i("init",(()=>{t.params.keyboard.enabled&&p()})),i("destroy",(()=>{t.keyboard.enabled&&u()})),Object.assign(t.keyboard,{enable:p,disable:u})},function(e){let{swiper:t,extendParams:s,on:a,emit:i}=e;const n=r();let l;s({mousewheel:{enabled:!1,releaseOnEdges:!1,invert:!1,forceToAxis:!1,sensitivity:1,eventsTarget:"container",thresholdDelta:null,thresholdTime:null}}),t.mousewheel={enabled:!1};let o,c=u();const h=[];function m(){t.enabled&&(t.mouseEntered=!0)}function f(){t.enabled&&(t.mouseEntered=!1)}function g(e){return!(t.params.mousewheel.thresholdDelta&&e.delta=6&&u()-c<60||(e.direction<0?t.isEnd&&!t.params.loop||t.animating||(t.slideNext(),i("scroll",e.raw)):t.isBeginning&&!t.params.loop||t.animating||(t.slidePrev(),i("scroll",e.raw)),c=(new n.Date).getTime(),!1)))}function v(e){let s=e,a=!0;if(!t.enabled)return;const r=t.params.mousewheel;t.params.cssMode&&s.preventDefault();let n=t.$el;if("container"!==t.params.mousewheel.eventsTarget&&(n=d(t.params.mousewheel.eventsTarget)),!t.mouseEntered&&!n[0].contains(s.target)&&!r.releaseOnEdges)return!0;s.originalEvent&&(s=s.originalEvent);let c=0;const m=t.rtlTranslate?-1:1,f=function(e){let t=0,s=0,a=0,i=0;return"detail"in e&&(s=e.detail),"wheelDelta"in e&&(s=-e.wheelDelta/120),"wheelDeltaY"in e&&(s=-e.wheelDeltaY/120),"wheelDeltaX"in e&&(t=-e.wheelDeltaX/120),"axis"in e&&e.axis===e.HORIZONTAL_AXIS&&(t=s,s=0),a=10*t,i=10*s,"deltaY"in e&&(i=e.deltaY),"deltaX"in e&&(a=e.deltaX),e.shiftKey&&!a&&(a=i,i=0),(a||i)&&e.deltaMode&&(1===e.deltaMode?(a*=40,i*=40):(a*=800,i*=800)),a&&!t&&(t=a<1?-1:1),i&&!s&&(s=i<1?-1:1),{spinX:t,spinY:s,pixelX:a,pixelY:i}}(s);if(r.forceToAxis)if(t.isHorizontal()){if(!(Math.abs(f.pixelX)>Math.abs(f.pixelY)))return!0;c=-f.pixelX*m}else{if(!(Math.abs(f.pixelY)>Math.abs(f.pixelX)))return!0;c=-f.pixelY}else c=Math.abs(f.pixelX)>Math.abs(f.pixelY)?-f.pixelX*m:-f.pixelY;if(0===c)return!0;r.invert&&(c=-c);let v=t.getTranslate()+c*r.sensitivity;if(v>=t.minTranslate()&&(v=t.minTranslate()),v<=t.maxTranslate()&&(v=t.maxTranslate()),a=!!t.params.loop||!(v===t.minTranslate()||v===t.maxTranslate()),a&&t.params.nested&&s.stopPropagation(),t.params.freeMode&&t.params.freeMode.enabled){const e={time:u(),delta:Math.abs(c),direction:Math.sign(c)},a=o&&e.time=t.minTranslate()&&(n=t.minTranslate()),n<=t.maxTranslate()&&(n=t.maxTranslate()),t.setTransition(0),t.setTranslate(n),t.updateProgress(),t.updateActiveIndex(),t.updateSlidesClasses(),(!d&&t.isBeginning||!u&&t.isEnd)&&t.updateSlidesClasses(),t.params.freeMode.sticky){clearTimeout(l),l=void 0,h.length>=15&&h.shift();const s=h.length?h[h.length-1]:void 0,a=h[0];if(h.push(e),s&&(e.delta>s.delta||e.direction!==s.direction))h.splice(0);else if(h.length>=15&&e.time-a.time<500&&a.delta-e.delta>=1&&e.delta<=6){const s=c>0?.8:.2;o=e,h.splice(0),l=p((()=>{t.slideToClosest(t.params.speed,!0,void 0,s)}),0)}l||(l=p((()=>{o=e,h.splice(0),t.slideToClosest(t.params.speed,!0,void 0,.5)}),500))}if(a||i("scroll",s),t.params.autoplay&&t.params.autoplayDisableOnInteraction&&t.autoplay.stop(),n===t.minTranslate()||n===t.maxTranslate())return!0}}else{const s={time:u(),delta:Math.abs(c),direction:Math.sign(c),raw:e};h.length>=2&&h.shift();const a=h.length?h[h.length-1]:void 0;if(h.push(s),a?(s.direction!==a.direction||s.delta>a.delta||s.time>a.time+150)&&g(s):g(s),function(e){const s=t.params.mousewheel;if(e.direction<0){if(t.isEnd&&!t.params.loop&&s.releaseOnEdges)return!0}else if(t.isBeginning&&!t.params.loop&&s.releaseOnEdges)return!0;return!1}(s))return!0}return s.preventDefault?s.preventDefault():s.returnValue=!1,!1}function w(e){let s=t.$el;"container"!==t.params.mousewheel.eventsTarget&&(s=d(t.params.mousewheel.eventsTarget)),s[e]("mouseenter",m),s[e]("mouseleave",f),s[e]("wheel",v)}function b(){return t.params.cssMode?(t.wrapperEl.removeEventListener("wheel",v),!0):!t.mousewheel.enabled&&(w("on"),t.mousewheel.enabled=!0,!0)}function x(){return t.params.cssMode?(t.wrapperEl.addEventListener(event,v),!0):!!t.mousewheel.enabled&&(w("off"),t.mousewheel.enabled=!1,!0)}a("init",(()=>{!t.params.mousewheel.enabled&&t.params.cssMode&&x(),t.params.mousewheel.enabled&&b()})),a("destroy",(()=>{t.params.cssMode&&b(),t.mousewheel.enabled&&x()})),Object.assign(t.mousewheel,{enable:b,disable:x})},function(e){let{swiper:t,extendParams:s,on:a,emit:i}=e;function r(e){let s;return e&&(s=d(e),t.params.uniqueNavElements&&"string"==typeof e&&s.length>1&&1===t.$el.find(e).length&&(s=t.$el.find(e))),s}function n(e,s){const a=t.params.navigation;e&&e.length>0&&(e[s?"addClass":"removeClass"](a.disabledClass),e[0]&&"BUTTON"===e[0].tagName&&(e[0].disabled=s),t.params.watchOverflow&&t.enabled&&e[t.isLocked?"addClass":"removeClass"](a.lockClass))}function l(){if(t.params.loop)return;const{$nextEl:e,$prevEl:s}=t.navigation;n(s,t.isBeginning&&!t.params.rewind),n(e,t.isEnd&&!t.params.rewind)}function o(e){e.preventDefault(),(!t.isBeginning||t.params.loop||t.params.rewind)&&(t.slidePrev(),i("navigationPrev"))}function c(e){e.preventDefault(),(!t.isEnd||t.params.loop||t.params.rewind)&&(t.slideNext(),i("navigationNext"))}function p(){const e=t.params.navigation;if(t.params.navigation=F(t,t.originalParams.navigation,t.params.navigation,{nextEl:"swiper-button-next",prevEl:"swiper-button-prev"}),!e.nextEl&&!e.prevEl)return;const s=r(e.nextEl),a=r(e.prevEl);s&&s.length>0&&s.on("click",c),a&&a.length>0&&a.on("click",o),Object.assign(t.navigation,{$nextEl:s,nextEl:s&&s[0],$prevEl:a,prevEl:a&&a[0]}),t.enabled||(s&&s.addClass(e.lockClass),a&&a.addClass(e.lockClass))}function u(){const{$nextEl:e,$prevEl:s}=t.navigation;e&&e.length&&(e.off("click",c),e.removeClass(t.params.navigation.disabledClass)),s&&s.length&&(s.off("click",o),s.removeClass(t.params.navigation.disabledClass))}s({navigation:{nextEl:null,prevEl:null,hideOnClick:!1,disabledClass:"swiper-button-disabled",hiddenClass:"swiper-button-hidden",lockClass:"swiper-button-lock",navigationDisabledClass:"swiper-navigation-disabled"}}),t.navigation={nextEl:null,$nextEl:null,prevEl:null,$prevEl:null},a("init",(()=>{!1===t.params.navigation.enabled?h():(p(),l())})),a("toEdge fromEdge lock unlock",(()=>{l()})),a("destroy",(()=>{u()})),a("enable disable",(()=>{const{$nextEl:e,$prevEl:s}=t.navigation;e&&e[t.enabled?"removeClass":"addClass"](t.params.navigation.lockClass),s&&s[t.enabled?"removeClass":"addClass"](t.params.navigation.lockClass)})),a("click",((e,s)=>{const{$nextEl:a,$prevEl:r}=t.navigation,n=s.target;if(t.params.navigation.hideOnClick&&!d(n).is(r)&&!d(n).is(a)){if(t.pagination&&t.params.pagination&&t.params.pagination.clickable&&(t.pagination.el===n||t.pagination.el.contains(n)))return;let e;a?e=a.hasClass(t.params.navigation.hiddenClass):r&&(e=r.hasClass(t.params.navigation.hiddenClass)),i(!0===e?"navigationShow":"navigationHide"),a&&a.toggleClass(t.params.navigation.hiddenClass),r&&r.toggleClass(t.params.navigation.hiddenClass)}}));const h=()=>{t.$el.addClass(t.params.navigation.navigationDisabledClass),u()};Object.assign(t.navigation,{enable:()=>{t.$el.removeClass(t.params.navigation.navigationDisabledClass),p(),l()},disable:h,update:l,init:p,destroy:u})},function(e){let{swiper:t,extendParams:s,on:a,emit:i}=e;const r="swiper-pagination";let n;s({pagination:{el:null,bulletElement:"span",clickable:!1,hideOnClick:!1,renderBullet:null,renderProgressbar:null,renderFraction:null,renderCustom:null,progressbarOpposite:!1,type:"bullets",dynamicBullets:!1,dynamicMainBullets:1,formatFractionCurrent:e=>e,formatFractionTotal:e=>e,bulletClass:`${r}-bullet`,bulletActiveClass:`${r}-bullet-active`,modifierClass:`${r}-`,currentClass:`${r}-current`,totalClass:`${r}-total`,hiddenClass:`${r}-hidden`,progressbarFillClass:`${r}-progressbar-fill`,progressbarOppositeClass:`${r}-progressbar-opposite`,clickableClass:`${r}-clickable`,lockClass:`${r}-lock`,horizontalClass:`${r}-horizontal`,verticalClass:`${r}-vertical`,paginationDisabledClass:`${r}-disabled`}}),t.pagination={el:null,$el:null,bullets:[]};let l=0;function o(){return!t.params.pagination.el||!t.pagination.el||!t.pagination.$el||0===t.pagination.$el.length}function c(e,s){const{bulletActiveClass:a}=t.params.pagination;e[s]().addClass(`${a}-${s}`)[s]().addClass(`${a}-${s}-${s}`)}function p(){const e=t.rtl,s=t.params.pagination;if(o())return;const a=t.virtual&&t.params.virtual.enabled?t.virtual.slides.length:t.slides.length,r=t.pagination.$el;let p;const u=t.params.loop?Math.ceil((a-2*t.loopedSlides)/t.params.slidesPerGroup):t.snapGrid.length;if(t.params.loop?(p=Math.ceil((t.activeIndex-t.loopedSlides)/t.params.slidesPerGroup),p>a-1-2*t.loopedSlides&&(p-=a-2*t.loopedSlides),p>u-1&&(p-=u),p<0&&"bullets"!==t.params.paginationType&&(p=u+p)):p=void 0!==t.snapIndex?t.snapIndex:t.activeIndex||0,"bullets"===s.type&&t.pagination.bullets&&t.pagination.bullets.length>0){const a=t.pagination.bullets;let i,o,u;if(s.dynamicBullets&&(n=a.eq(0)[t.isHorizontal()?"outerWidth":"outerHeight"](!0),r.css(t.isHorizontal()?"width":"height",n*(s.dynamicMainBullets+4)+"px"),s.dynamicMainBullets>1&&void 0!==t.previousIndex&&(l+=p-(t.previousIndex-t.loopedSlides||0),l>s.dynamicMainBullets-1?l=s.dynamicMainBullets-1:l<0&&(l=0)),i=Math.max(p-l,0),o=i+(Math.min(a.length,s.dynamicMainBullets)-1),u=(o+i)/2),a.removeClass(["","-next","-next-next","-prev","-prev-prev","-main"].map((e=>`${s.bulletActiveClass}${e}`)).join(" ")),r.length>1)a.each((e=>{const t=d(e),a=t.index();a===p&&t.addClass(s.bulletActiveClass),s.dynamicBullets&&(a>=i&&a<=o&&t.addClass(`${s.bulletActiveClass}-main`),a===i&&c(t,"prev"),a===o&&c(t,"next"))}));else{const e=a.eq(p),r=e.index();if(e.addClass(s.bulletActiveClass),s.dynamicBullets){const e=a.eq(i),n=a.eq(o);for(let e=i;e<=o;e+=1)a.eq(e).addClass(`${s.bulletActiveClass}-main`);if(t.params.loop)if(r>=a.length){for(let e=s.dynamicMainBullets;e>=0;e-=1)a.eq(a.length-e).addClass(`${s.bulletActiveClass}-main`);a.eq(a.length-s.dynamicMainBullets-1).addClass(`${s.bulletActiveClass}-prev`)}else c(e,"prev"),c(n,"next");else c(e,"prev"),c(n,"next")}}if(s.dynamicBullets){const i=Math.min(a.length,s.dynamicMainBullets+4),r=(n*i-n)/2-u*n,l=e?"right":"left";a.css(t.isHorizontal()?l:"top",`${r}px`)}}if("fraction"===s.type&&(r.find(U(s.currentClass)).text(s.formatFractionCurrent(p+1)),r.find(U(s.totalClass)).text(s.formatFractionTotal(u))),"progressbar"===s.type){let e;e=s.progressbarOpposite?t.isHorizontal()?"vertical":"horizontal":t.isHorizontal()?"horizontal":"vertical";const a=(p+1)/u;let i=1,n=1;"horizontal"===e?i=a:n=a,r.find(U(s.progressbarFillClass)).transform(`translate3d(0,0,0) scaleX(${i}) scaleY(${n})`).transition(t.params.speed)}"custom"===s.type&&s.renderCustom?(r.html(s.renderCustom(t,p+1,u)),i("paginationRender",r[0])):i("paginationUpdate",r[0]),t.params.watchOverflow&&t.enabled&&r[t.isLocked?"addClass":"removeClass"](s.lockClass)}function u(){const e=t.params.pagination;if(o())return;const s=t.virtual&&t.params.virtual.enabled?t.virtual.slides.length:t.slides.length,a=t.pagination.$el;let r="";if("bullets"===e.type){let i=t.params.loop?Math.ceil((s-2*t.loopedSlides)/t.params.slidesPerGroup):t.snapGrid.length;t.params.freeMode&&t.params.freeMode.enabled&&!t.params.loop&&i>s&&(i=s);for(let s=0;s`;a.html(r),t.pagination.bullets=a.find(U(e.bulletClass))}"fraction"===e.type&&(r=e.renderFraction?e.renderFraction.call(t,e.currentClass,e.totalClass):` / `,a.html(r)),"progressbar"===e.type&&(r=e.renderProgressbar?e.renderProgressbar.call(t,e.progressbarFillClass):``,a.html(r)),"custom"!==e.type&&i("paginationRender",t.pagination.$el[0])}function h(){t.params.pagination=F(t,t.originalParams.pagination,t.params.pagination,{el:"swiper-pagination"});const e=t.params.pagination;if(!e.el)return;let s=d(e.el);0!==s.length&&(t.params.uniqueNavElements&&"string"==typeof e.el&&s.length>1&&(s=t.$el.find(e.el),s.length>1&&(s=s.filter((e=>d(e).parents(".swiper")[0]===t.el)))),"bullets"===e.type&&e.clickable&&s.addClass(e.clickableClass),s.addClass(e.modifierClass+e.type),s.addClass(t.isHorizontal()?e.horizontalClass:e.verticalClass),"bullets"===e.type&&e.dynamicBullets&&(s.addClass(`${e.modifierClass}${e.type}-dynamic`),l=0,e.dynamicMainBullets<1&&(e.dynamicMainBullets=1)),"progressbar"===e.type&&e.progressbarOpposite&&s.addClass(e.progressbarOppositeClass),e.clickable&&s.on("click",U(e.bulletClass),(function(e){e.preventDefault();let s=d(this).index()*t.params.slidesPerGroup;t.params.loop&&(s+=t.loopedSlides),t.slideTo(s)})),Object.assign(t.pagination,{$el:s,el:s[0]}),t.enabled||s.addClass(e.lockClass))}function m(){const e=t.params.pagination;if(o())return;const s=t.pagination.$el;s.removeClass(e.hiddenClass),s.removeClass(e.modifierClass+e.type),s.removeClass(t.isHorizontal()?e.horizontalClass:e.verticalClass),t.pagination.bullets&&t.pagination.bullets.removeClass&&t.pagination.bullets.removeClass(e.bulletActiveClass),e.clickable&&s.off("click",U(e.bulletClass))}a("init",(()=>{!1===t.params.pagination.enabled?f():(h(),u(),p())})),a("activeIndexChange",(()=>{(t.params.loop||void 0===t.snapIndex)&&p()})),a("snapIndexChange",(()=>{t.params.loop||p()})),a("slidesLengthChange",(()=>{t.params.loop&&(u(),p())})),a("snapGridLengthChange",(()=>{t.params.loop||(u(),p())})),a("destroy",(()=>{m()})),a("enable disable",(()=>{const{$el:e}=t.pagination;e&&e[t.enabled?"removeClass":"addClass"](t.params.pagination.lockClass)})),a("lock unlock",(()=>{p()})),a("click",((e,s)=>{const a=s.target,{$el:r}=t.pagination;if(t.params.pagination.el&&t.params.pagination.hideOnClick&&r&&r.length>0&&!d(a).hasClass(t.params.pagination.bulletClass)){if(t.navigation&&(t.navigation.nextEl&&a===t.navigation.nextEl||t.navigation.prevEl&&a===t.navigation.prevEl))return;const e=r.hasClass(t.params.pagination.hiddenClass);i(!0===e?"paginationShow":"paginationHide"),r.toggleClass(t.params.pagination.hiddenClass)}}));const f=()=>{t.$el.addClass(t.params.pagination.paginationDisabledClass),t.pagination.$el&&t.pagination.$el.addClass(t.params.pagination.paginationDisabledClass),m()};Object.assign(t.pagination,{enable:()=>{t.$el.removeClass(t.params.pagination.paginationDisabledClass),t.pagination.$el&&t.pagination.$el.removeClass(t.params.pagination.paginationDisabledClass),h(),u(),p()},disable:f,render:u,update:p,init:h,destroy:m})},function(e){let{swiper:t,extendParams:s,on:i,emit:r}=e;const n=a();let l,o,c,u,h=!1,m=null,f=null;function g(){if(!t.params.scrollbar.el||!t.scrollbar.el)return;const{scrollbar:e,rtlTranslate:s,progress:a}=t,{$dragEl:i,$el:r}=e,n=t.params.scrollbar;let l=o,d=(c-o)*a;s?(d=-d,d>0?(l=o-d,d=0):-d+o>c&&(l=c+d)):d<0?(l=o+d,d=0):d+o>c&&(l=c-d),t.isHorizontal()?(i.transform(`translate3d(${d}px, 0, 0)`),i[0].style.width=`${l}px`):(i.transform(`translate3d(0px, ${d}px, 0)`),i[0].style.height=`${l}px`),n.hide&&(clearTimeout(m),r[0].style.opacity=1,m=setTimeout((()=>{r[0].style.opacity=0,r.transition(400)}),1e3))}function v(){if(!t.params.scrollbar.el||!t.scrollbar.el)return;const{scrollbar:e}=t,{$dragEl:s,$el:a}=e;s[0].style.width="",s[0].style.height="",c=t.isHorizontal()?a[0].offsetWidth:a[0].offsetHeight,u=t.size/(t.virtualSize+t.params.slidesOffsetBefore-(t.params.centeredSlides?t.snapGrid[0]:0)),o="auto"===t.params.scrollbar.dragSize?c*u:parseInt(t.params.scrollbar.dragSize,10),t.isHorizontal()?s[0].style.width=`${o}px`:s[0].style.height=`${o}px`,a[0].style.display=u>=1?"none":"",t.params.scrollbar.hide&&(a[0].style.opacity=0),t.params.watchOverflow&&t.enabled&&e.$el[t.isLocked?"addClass":"removeClass"](t.params.scrollbar.lockClass)}function w(e){return t.isHorizontal()?"touchstart"===e.type||"touchmove"===e.type?e.targetTouches[0].clientX:e.clientX:"touchstart"===e.type||"touchmove"===e.type?e.targetTouches[0].clientY:e.clientY}function b(e){const{scrollbar:s,rtlTranslate:a}=t,{$el:i}=s;let r;r=(w(e)-i.offset()[t.isHorizontal()?"left":"top"]-(null!==l?l:o/2))/(c-o),r=Math.max(Math.min(r,1),0),a&&(r=1-r);const n=t.minTranslate()+(t.maxTranslate()-t.minTranslate())*r;t.updateProgress(n),t.setTranslate(n),t.updateActiveIndex(),t.updateSlidesClasses()}function x(e){const s=t.params.scrollbar,{scrollbar:a,$wrapperEl:i}=t,{$el:n,$dragEl:o}=a;h=!0,l=e.target===o[0]||e.target===o?w(e)-e.target.getBoundingClientRect()[t.isHorizontal()?"left":"top"]:null,e.preventDefault(),e.stopPropagation(),i.transition(100),o.transition(100),b(e),clearTimeout(f),n.transition(0),s.hide&&n.css("opacity",1),t.params.cssMode&&t.$wrapperEl.css("scroll-snap-type","none"),r("scrollbarDragStart",e)}function y(e){const{scrollbar:s,$wrapperEl:a}=t,{$el:i,$dragEl:n}=s;h&&(e.preventDefault?e.preventDefault():e.returnValue=!1,b(e),a.transition(0),i.transition(0),n.transition(0),r("scrollbarDragMove",e))}function E(e){const s=t.params.scrollbar,{scrollbar:a,$wrapperEl:i}=t,{$el:n}=a;h&&(h=!1,t.params.cssMode&&(t.$wrapperEl.css("scroll-snap-type",""),i.transition("")),s.hide&&(clearTimeout(f),f=p((()=>{n.css("opacity",0),n.transition(400)}),1e3)),r("scrollbarDragEnd",e),s.snapOnRelease&&t.slideToClosest())}function C(e){const{scrollbar:s,touchEventsTouch:a,touchEventsDesktop:i,params:r,support:l}=t,o=s.$el;if(!o)return;const d=o[0],c=!(!l.passiveListener||!r.passiveListeners)&&{passive:!1,capture:!1},p=!(!l.passiveListener||!r.passiveListeners)&&{passive:!0,capture:!1};if(!d)return;const u="on"===e?"addEventListener":"removeEventListener";l.touch?(d[u](a.start,x,c),d[u](a.move,y,c),d[u](a.end,E,p)):(d[u](i.start,x,c),n[u](i.move,y,c),n[u](i.end,E,p))}function T(){const{scrollbar:e,$el:s}=t;t.params.scrollbar=F(t,t.originalParams.scrollbar,t.params.scrollbar,{el:"swiper-scrollbar"});const a=t.params.scrollbar;if(!a.el)return;let i=d(a.el);t.params.uniqueNavElements&&"string"==typeof a.el&&i.length>1&&1===s.find(a.el).length&&(i=s.find(a.el)),i.addClass(t.isHorizontal()?a.horizontalClass:a.verticalClass);let r=i.find(`.${t.params.scrollbar.dragClass}`);0===r.length&&(r=d(`
`),i.append(r)),Object.assign(e,{$el:i,el:i[0],$dragEl:r,dragEl:r[0]}),a.draggable&&t.params.scrollbar.el&&t.scrollbar.el&&C("on"),i&&i[t.enabled?"removeClass":"addClass"](t.params.scrollbar.lockClass)}function $(){const e=t.params.scrollbar,s=t.scrollbar.$el;s&&s.removeClass(t.isHorizontal()?e.horizontalClass:e.verticalClass),t.params.scrollbar.el&&t.scrollbar.el&&C("off")}s({scrollbar:{el:null,dragSize:"auto",hide:!1,draggable:!1,snapOnRelease:!0,lockClass:"swiper-scrollbar-lock",dragClass:"swiper-scrollbar-drag",scrollbarDisabledClass:"swiper-scrollbar-disabled",horizontalClass:"swiper-scrollbar-horizontal",verticalClass:"swiper-scrollbar-vertical"}}),t.scrollbar={el:null,dragEl:null,$el:null,$dragEl:null},i("init",(()=>{!1===t.params.scrollbar.enabled?S():(T(),v(),g())})),i("update resize observerUpdate lock unlock",(()=>{v()})),i("setTranslate",(()=>{g()})),i("setTransition",((e,s)=>{!function(e){t.params.scrollbar.el&&t.scrollbar.el&&t.scrollbar.$dragEl.transition(e)}(s)})),i("enable disable",(()=>{const{$el:e}=t.scrollbar;e&&e[t.enabled?"removeClass":"addClass"](t.params.scrollbar.lockClass)})),i("destroy",(()=>{$()}));const S=()=>{t.$el.addClass(t.params.scrollbar.scrollbarDisabledClass),t.scrollbar.$el&&t.scrollbar.$el.addClass(t.params.scrollbar.scrollbarDisabledClass),$()};Object.assign(t.scrollbar,{enable:()=>{t.$el.removeClass(t.params.scrollbar.scrollbarDisabledClass),t.scrollbar.$el&&t.scrollbar.$el.removeClass(t.params.scrollbar.scrollbarDisabledClass),T(),v(),g()},disable:S,updateSize:v,setTranslate:g,init:T,destroy:$})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({parallax:{enabled:!1}});const i=(e,s)=>{const{rtl:a}=t,i=d(e),r=a?-1:1,n=i.attr("data-swiper-parallax")||"0";let l=i.attr("data-swiper-parallax-x"),o=i.attr("data-swiper-parallax-y");const c=i.attr("data-swiper-parallax-scale"),p=i.attr("data-swiper-parallax-opacity");if(l||o?(l=l||"0",o=o||"0"):t.isHorizontal()?(l=n,o="0"):(o=n,l="0"),l=l.indexOf("%")>=0?parseInt(l,10)*s*r+"%":l*s*r+"px",o=o.indexOf("%")>=0?parseInt(o,10)*s+"%":o*s+"px",null!=p){const e=p-(p-1)*(1-Math.abs(s));i[0].style.opacity=e}if(null==c)i.transform(`translate3d(${l}, ${o}, 0px)`);else{const e=c-(c-1)*(1-Math.abs(s));i.transform(`translate3d(${l}, ${o}, 0px) scale(${e})`)}},r=()=>{const{$el:e,slides:s,progress:a,snapGrid:r}=t;e.children("[data-swiper-parallax], [data-swiper-parallax-x], [data-swiper-parallax-y], [data-swiper-parallax-opacity], [data-swiper-parallax-scale]").each((e=>{i(e,a)})),s.each(((e,s)=>{let n=e.progress;t.params.slidesPerGroup>1&&"auto"!==t.params.slidesPerView&&(n+=Math.ceil(s/2)-a*(r.length-1)),n=Math.min(Math.max(n,-1),1),d(e).find("[data-swiper-parallax], [data-swiper-parallax-x], [data-swiper-parallax-y], [data-swiper-parallax-opacity], [data-swiper-parallax-scale]").each((e=>{i(e,n)}))}))};a("beforeInit",(()=>{t.params.parallax.enabled&&(t.params.watchSlidesProgress=!0,t.originalParams.watchSlidesProgress=!0)})),a("init",(()=>{t.params.parallax.enabled&&r()})),a("setTranslate",(()=>{t.params.parallax.enabled&&r()})),a("setTransition",((e,s)=>{t.params.parallax.enabled&&function(e){void 0===e&&(e=t.params.speed);const{$el:s}=t;s.find("[data-swiper-parallax], [data-swiper-parallax-x], [data-swiper-parallax-y], [data-swiper-parallax-opacity], [data-swiper-parallax-scale]").each((t=>{const s=d(t);let a=parseInt(s.attr("data-swiper-parallax-duration"),10)||e;0===e&&(a=0),s.transition(a)}))}(s)}))},function(e){let{swiper:t,extendParams:s,on:a,emit:i}=e;const n=r();s({zoom:{enabled:!1,maxRatio:3,minRatio:1,toggle:!0,containerClass:"swiper-zoom-container",zoomedSlideClass:"swiper-slide-zoomed"}}),t.zoom={enabled:!1};let l,o,c,p=1,u=!1;const m={$slideEl:void 0,slideWidth:void 0,slideHeight:void 0,$imageEl:void 0,$imageWrapEl:void 0,maxRatio:3},f={isTouched:void 0,isMoved:void 0,currentX:void 0,currentY:void 0,minX:void 0,minY:void 0,maxX:void 0,maxY:void 0,width:void 0,height:void 0,startX:void 0,startY:void 0,touchesStart:{},touchesCurrent:{}},g={x:void 0,y:void 0,prevPositionX:void 0,prevPositionY:void 0,prevTime:void 0};let v=1;function w(e){if(e.targetTouches.length<2)return 1;const t=e.targetTouches[0].pageX,s=e.targetTouches[0].pageY,a=e.targetTouches[1].pageX,i=e.targetTouches[1].pageY;return Math.sqrt((a-t)**2+(i-s)**2)}function b(e){const s=t.support,a=t.params.zoom;if(o=!1,c=!1,!s.gestures){if("touchstart"!==e.type||"touchstart"===e.type&&e.targetTouches.length<2)return;o=!0,m.scaleStart=w(e)}m.$slideEl&&m.$slideEl.length||(m.$slideEl=d(e.target).closest(`.${t.params.slideClass}`),0===m.$slideEl.length&&(m.$slideEl=t.slides.eq(t.activeIndex)),m.$imageEl=m.$slideEl.find(`.${a.containerClass}`).eq(0).find("picture, img, svg, canvas, .swiper-zoom-target").eq(0),m.$imageWrapEl=m.$imageEl.parent(`.${a.containerClass}`),m.maxRatio=m.$imageWrapEl.attr("data-swiper-zoom")||a.maxRatio,0!==m.$imageWrapEl.length)?(m.$imageEl&&m.$imageEl.transition(0),u=!0):m.$imageEl=void 0}function x(e){const s=t.support,a=t.params.zoom,i=t.zoom;if(!s.gestures){if("touchmove"!==e.type||"touchmove"===e.type&&e.targetTouches.length<2)return;c=!0,m.scaleMove=w(e)}m.$imageEl&&0!==m.$imageEl.length?(s.gestures?i.scale=e.scale*p:i.scale=m.scaleMove/m.scaleStart*p,i.scale>m.maxRatio&&(i.scale=m.maxRatio-1+(i.scale-m.maxRatio+1)**.5),i.scalef.touchesStart.x))return void(f.isTouched=!1);if(!t.isHorizontal()&&(Math.floor(f.minY)===Math.floor(f.startY)&&f.touchesCurrent.yf.touchesStart.y))return void(f.isTouched=!1)}e.cancelable&&e.preventDefault(),e.stopPropagation(),f.isMoved=!0,f.currentX=f.touchesCurrent.x-f.touchesStart.x+f.startX,f.currentY=f.touchesCurrent.y-f.touchesStart.y+f.startY,f.currentXf.maxX&&(f.currentX=f.maxX-1+(f.currentX-f.maxX+1)**.8),f.currentYf.maxY&&(f.currentY=f.maxY-1+(f.currentY-f.maxY+1)**.8),g.prevPositionX||(g.prevPositionX=f.touchesCurrent.x),g.prevPositionY||(g.prevPositionY=f.touchesCurrent.y),g.prevTime||(g.prevTime=Date.now()),g.x=(f.touchesCurrent.x-g.prevPositionX)/(Date.now()-g.prevTime)/2,g.y=(f.touchesCurrent.y-g.prevPositionY)/(Date.now()-g.prevTime)/2,Math.abs(f.touchesCurrent.x-g.prevPositionX)<2&&(g.x=0),Math.abs(f.touchesCurrent.y-g.prevPositionY)<2&&(g.y=0),g.prevPositionX=f.touchesCurrent.x,g.prevPositionY=f.touchesCurrent.y,g.prevTime=Date.now(),m.$imageWrapEl.transform(`translate3d(${f.currentX}px, ${f.currentY}px,0)`)}}function C(){const e=t.zoom;m.$slideEl&&t.previousIndex!==t.activeIndex&&(m.$imageEl&&m.$imageEl.transform("translate3d(0,0,0) scale(1)"),m.$imageWrapEl&&m.$imageWrapEl.transform("translate3d(0,0,0)"),e.scale=1,p=1,m.$slideEl=void 0,m.$imageEl=void 0,m.$imageWrapEl=void 0)}function T(e){const s=t.zoom,a=t.params.zoom;if(m.$slideEl||(e&&e.target&&(m.$slideEl=d(e.target).closest(`.${t.params.slideClass}`)),m.$slideEl||(t.params.virtual&&t.params.virtual.enabled&&t.virtual?m.$slideEl=t.$wrapperEl.children(`.${t.params.slideActiveClass}`):m.$slideEl=t.slides.eq(t.activeIndex)),m.$imageEl=m.$slideEl.find(`.${a.containerClass}`).eq(0).find("picture, img, svg, canvas, .swiper-zoom-target").eq(0),m.$imageWrapEl=m.$imageEl.parent(`.${a.containerClass}`)),!m.$imageEl||0===m.$imageEl.length||!m.$imageWrapEl||0===m.$imageWrapEl.length)return;let i,r,l,o,c,u,h,g,v,w,b,x,y,E,C,T,$,S;t.params.cssMode&&(t.wrapperEl.style.overflow="hidden",t.wrapperEl.style.touchAction="none"),m.$slideEl.addClass(`${a.zoomedSlideClass}`),void 0===f.touchesStart.x&&e?(i="touchend"===e.type?e.changedTouches[0].pageX:e.pageX,r="touchend"===e.type?e.changedTouches[0].pageY:e.pageY):(i=f.touchesStart.x,r=f.touchesStart.y),s.scale=m.$imageWrapEl.attr("data-swiper-zoom")||a.maxRatio,p=m.$imageWrapEl.attr("data-swiper-zoom")||a.maxRatio,e?($=m.$slideEl[0].offsetWidth,S=m.$slideEl[0].offsetHeight,l=m.$slideEl.offset().left+n.scrollX,o=m.$slideEl.offset().top+n.scrollY,c=l+$/2-i,u=o+S/2-r,v=m.$imageEl[0].offsetWidth,w=m.$imageEl[0].offsetHeight,b=v*s.scale,x=w*s.scale,y=Math.min($/2-b/2,0),E=Math.min(S/2-x/2,0),C=-y,T=-E,h=c*s.scale,g=u*s.scale,hC&&(h=C),gT&&(g=T)):(h=0,g=0),m.$imageWrapEl.transition(300).transform(`translate3d(${h}px, ${g}px,0)`),m.$imageEl.transition(300).transform(`translate3d(0,0,0) scale(${s.scale})`)}function $(){const e=t.zoom,s=t.params.zoom;m.$slideEl||(t.params.virtual&&t.params.virtual.enabled&&t.virtual?m.$slideEl=t.$wrapperEl.children(`.${t.params.slideActiveClass}`):m.$slideEl=t.slides.eq(t.activeIndex),m.$imageEl=m.$slideEl.find(`.${s.containerClass}`).eq(0).find("picture, img, svg, canvas, .swiper-zoom-target").eq(0),m.$imageWrapEl=m.$imageEl.parent(`.${s.containerClass}`)),m.$imageEl&&0!==m.$imageEl.length&&m.$imageWrapEl&&0!==m.$imageWrapEl.length&&(t.params.cssMode&&(t.wrapperEl.style.overflow="",t.wrapperEl.style.touchAction=""),e.scale=1,p=1,m.$imageWrapEl.transition(300).transform("translate3d(0,0,0)"),m.$imageEl.transition(300).transform("translate3d(0,0,0) scale(1)"),m.$slideEl.removeClass(`${s.zoomedSlideClass}`),m.$slideEl=void 0)}function S(e){const s=t.zoom;s.scale&&1!==s.scale?$():T(e)}function M(){const e=t.support;return{passiveListener:!("touchstart"!==t.touchEvents.start||!e.passiveListener||!t.params.passiveListeners)&&{passive:!0,capture:!1},activeListenerWithCapture:!e.passiveListener||{passive:!1,capture:!0}}}function P(){return`.${t.params.slideClass}`}function k(e){const{passiveListener:s}=M(),a=P();t.$wrapperEl[e]("gesturestart",a,b,s),t.$wrapperEl[e]("gesturechange",a,x,s),t.$wrapperEl[e]("gestureend",a,y,s)}function z(){l||(l=!0,k("on"))}function L(){l&&(l=!1,k("off"))}function O(){const e=t.zoom;if(e.enabled)return;e.enabled=!0;const s=t.support,{passiveListener:a,activeListenerWithCapture:i}=M(),r=P();s.gestures?(t.$wrapperEl.on(t.touchEvents.start,z,a),t.$wrapperEl.on(t.touchEvents.end,L,a)):"touchstart"===t.touchEvents.start&&(t.$wrapperEl.on(t.touchEvents.start,r,b,a),t.$wrapperEl.on(t.touchEvents.move,r,x,i),t.$wrapperEl.on(t.touchEvents.end,r,y,a),t.touchEvents.cancel&&t.$wrapperEl.on(t.touchEvents.cancel,r,y,a)),t.$wrapperEl.on(t.touchEvents.move,`.${t.params.zoom.containerClass}`,E,i)}function I(){const e=t.zoom;if(!e.enabled)return;const s=t.support;e.enabled=!1;const{passiveListener:a,activeListenerWithCapture:i}=M(),r=P();s.gestures?(t.$wrapperEl.off(t.touchEvents.start,z,a),t.$wrapperEl.off(t.touchEvents.end,L,a)):"touchstart"===t.touchEvents.start&&(t.$wrapperEl.off(t.touchEvents.start,r,b,a),t.$wrapperEl.off(t.touchEvents.move,r,x,i),t.$wrapperEl.off(t.touchEvents.end,r,y,a),t.touchEvents.cancel&&t.$wrapperEl.off(t.touchEvents.cancel,r,y,a)),t.$wrapperEl.off(t.touchEvents.move,`.${t.params.zoom.containerClass}`,E,i)}Object.defineProperty(t.zoom,"scale",{get:()=>v,set(e){if(v!==e){const t=m.$imageEl?m.$imageEl[0]:void 0,s=m.$slideEl?m.$slideEl[0]:void 0;i("zoomChange",e,t,s)}v=e}}),a("init",(()=>{t.params.zoom.enabled&&O()})),a("destroy",(()=>{I()})),a("touchStart",((e,s)=>{t.zoom.enabled&&function(e){const s=t.device;m.$imageEl&&0!==m.$imageEl.length&&(f.isTouched||(s.android&&e.cancelable&&e.preventDefault(),f.isTouched=!0,f.touchesStart.x="touchstart"===e.type?e.targetTouches[0].pageX:e.pageX,f.touchesStart.y="touchstart"===e.type?e.targetTouches[0].pageY:e.pageY))}(s)})),a("touchEnd",((e,s)=>{t.zoom.enabled&&function(){const e=t.zoom;if(!m.$imageEl||0===m.$imageEl.length)return;if(!f.isTouched||!f.isMoved)return f.isTouched=!1,void(f.isMoved=!1);f.isTouched=!1,f.isMoved=!1;let s=300,a=300;const i=g.x*s,r=f.currentX+i,n=g.y*a,l=f.currentY+n;0!==g.x&&(s=Math.abs((r-f.currentX)/g.x)),0!==g.y&&(a=Math.abs((l-f.currentY)/g.y));const o=Math.max(s,a);f.currentX=r,f.currentY=l;const d=f.width*e.scale,c=f.height*e.scale;f.minX=Math.min(m.slideWidth/2-d/2,0),f.maxX=-f.minX,f.minY=Math.min(m.slideHeight/2-c/2,0),f.maxY=-f.minY,f.currentX=Math.max(Math.min(f.currentX,f.maxX),f.minX),f.currentY=Math.max(Math.min(f.currentY,f.maxY),f.minY),m.$imageWrapEl.transition(o).transform(`translate3d(${f.currentX}px, ${f.currentY}px,0)`)}()})),a("doubleTap",((e,s)=>{!t.animating&&t.params.zoom.enabled&&t.zoom.enabled&&t.params.zoom.toggle&&S(s)})),a("transitionEnd",(()=>{t.zoom.enabled&&t.params.zoom.enabled&&C()})),a("slideChange",(()=>{t.zoom.enabled&&t.params.zoom.enabled&&t.params.cssMode&&C()})),Object.assign(t.zoom,{enable:O,disable:I,in:T,out:$,toggle:S})},function(e){let{swiper:t,extendParams:s,on:a,emit:i}=e;s({lazy:{checkInView:!1,enabled:!1,loadPrevNext:!1,loadPrevNextAmount:1,loadOnTransitionStart:!1,scrollingElement:"",elementClass:"swiper-lazy",loadingClass:"swiper-lazy-loading",loadedClass:"swiper-lazy-loaded",preloaderClass:"swiper-lazy-preloader"}}),t.lazy={};let n=!1,l=!1;function o(e,s){void 0===s&&(s=!0);const a=t.params.lazy;if(void 0===e)return;if(0===t.slides.length)return;const r=t.virtual&&t.params.virtual.enabled?t.$wrapperEl.children(`.${t.params.slideClass}[data-swiper-slide-index="${e}"]`):t.slides.eq(e),n=r.find(`.${a.elementClass}:not(.${a.loadedClass}):not(.${a.loadingClass})`);!r.hasClass(a.elementClass)||r.hasClass(a.loadedClass)||r.hasClass(a.loadingClass)||n.push(r[0]),0!==n.length&&n.each((e=>{const n=d(e);n.addClass(a.loadingClass);const l=n.attr("data-background"),c=n.attr("data-src"),p=n.attr("data-srcset"),u=n.attr("data-sizes"),h=n.parent("picture");t.loadImage(n[0],c||l,p,u,!1,(()=>{if(null!=t&&t&&(!t||t.params)&&!t.destroyed){if(l?(n.css("background-image",`url("${l}")`),n.removeAttr("data-background")):(p&&(n.attr("srcset",p),n.removeAttr("data-srcset")),u&&(n.attr("sizes",u),n.removeAttr("data-sizes")),h.length&&h.children("source").each((e=>{const t=d(e);t.attr("data-srcset")&&(t.attr("srcset",t.attr("data-srcset")),t.removeAttr("data-srcset"))})),c&&(n.attr("src",c),n.removeAttr("data-src"))),n.addClass(a.loadedClass).removeClass(a.loadingClass),r.find(`.${a.preloaderClass}`).remove(),t.params.loop&&s){const e=r.attr("data-swiper-slide-index");if(r.hasClass(t.params.slideDuplicateClass)){o(t.$wrapperEl.children(`[data-swiper-slide-index="${e}"]:not(.${t.params.slideDuplicateClass})`).index(),!1)}else{o(t.$wrapperEl.children(`.${t.params.slideDuplicateClass}[data-swiper-slide-index="${e}"]`).index(),!1)}}i("lazyImageReady",r[0],n[0]),t.params.autoHeight&&t.updateAutoHeight()}})),i("lazyImageLoad",r[0],n[0])}))}function c(){const{$wrapperEl:e,params:s,slides:a,activeIndex:i}=t,r=t.virtual&&s.virtual.enabled,n=s.lazy;let c=s.slidesPerView;function p(t){if(r){if(e.children(`.${s.slideClass}[data-swiper-slide-index="${t}"]`).length)return!0}else if(a[t])return!0;return!1}function u(e){return r?d(e).attr("data-swiper-slide-index"):d(e).index()}if("auto"===c&&(c=0),l||(l=!0),t.params.watchSlidesProgress)e.children(`.${s.slideVisibleClass}`).each((e=>{o(r?d(e).attr("data-swiper-slide-index"):d(e).index())}));else if(c>1)for(let e=i;e1||n.loadPrevNextAmount&&n.loadPrevNextAmount>1){const e=n.loadPrevNextAmount,t=Math.ceil(c),s=Math.min(i+t+Math.max(e,t),a.length),r=Math.max(i-Math.max(t,e),0);for(let e=i+t;e0&&o(u(t));const a=e.children(`.${s.slidePrevClass}`);a.length>0&&o(u(a))}}function p(){const e=r();if(!t||t.destroyed)return;const s=t.params.lazy.scrollingElement?d(t.params.lazy.scrollingElement):d(e),a=s[0]===e,i=a?e.innerWidth:s[0].offsetWidth,l=a?e.innerHeight:s[0].offsetHeight,o=t.$el.offset(),{rtlTranslate:u}=t;let h=!1;u&&(o.left-=t.$el[0].scrollLeft);const m=[[o.left,o.top],[o.left+t.width,o.top],[o.left,o.top+t.height],[o.left+t.width,o.top+t.height]];for(let e=0;e=0&&t[0]<=i&&t[1]>=0&&t[1]<=l){if(0===t[0]&&0===t[1])continue;h=!0}}const f=!("touchstart"!==t.touchEvents.start||!t.support.passiveListener||!t.params.passiveListeners)&&{passive:!0,capture:!1};h?(c(),s.off("scroll",p,f)):n||(n=!0,s.on("scroll",p,f))}a("beforeInit",(()=>{t.params.lazy.enabled&&t.params.preloadImages&&(t.params.preloadImages=!1)})),a("init",(()=>{t.params.lazy.enabled&&(t.params.lazy.checkInView?p():c())})),a("scroll",(()=>{t.params.freeMode&&t.params.freeMode.enabled&&!t.params.freeMode.sticky&&c()})),a("scrollbarDragMove resize _freeModeNoMomentumRelease",(()=>{t.params.lazy.enabled&&(t.params.lazy.checkInView?p():c())})),a("transitionStart",(()=>{t.params.lazy.enabled&&(t.params.lazy.loadOnTransitionStart||!t.params.lazy.loadOnTransitionStart&&!l)&&(t.params.lazy.checkInView?p():c())})),a("transitionEnd",(()=>{t.params.lazy.enabled&&!t.params.lazy.loadOnTransitionStart&&(t.params.lazy.checkInView?p():c())})),a("slideChange",(()=>{const{lazy:e,cssMode:s,watchSlidesProgress:a,touchReleaseOnEdges:i,resistanceRatio:r}=t.params;e.enabled&&(s||a&&(i||0===r))&&c()})),a("destroy",(()=>{t.$el&&t.$el.find(`.${t.params.lazy.loadingClass}`).removeClass(t.params.lazy.loadingClass)})),Object.assign(t.lazy,{load:c,loadInSlide:o})},function(e){let{swiper:t,extendParams:s,on:a}=e;function i(e,t){const s=function(){let e,t,s;return(a,i)=>{for(t=-1,e=a.length;e-t>1;)s=e+t>>1,a[s]<=i?t=s:e=s;return e}}();let a,i;return this.x=e,this.y=t,this.lastIndex=e.length-1,this.interpolate=function(e){return e?(i=s(this.x,e),a=i-1,(e-this.x[a])*(this.y[i]-this.y[a])/(this.x[i]-this.x[a])+this.y[a]):0},this}function r(){t.controller.control&&t.controller.spline&&(t.controller.spline=void 0,delete t.controller.spline)}s({controller:{control:void 0,inverse:!1,by:"slide"}}),t.controller={control:void 0},a("beforeInit",(()=>{t.controller.control=t.params.controller.control})),a("update",(()=>{r()})),a("resize",(()=>{r()})),a("observerUpdate",(()=>{r()})),a("setTranslate",((e,s,a)=>{t.controller.control&&t.controller.setTranslate(s,a)})),a("setTransition",((e,s,a)=>{t.controller.control&&t.controller.setTransition(s,a)})),Object.assign(t.controller,{setTranslate:function(e,s){const a=t.controller.control;let r,n;const l=t.constructor;function o(e){const s=t.rtlTranslate?-t.translate:t.translate;"slide"===t.params.controller.by&&(!function(e){t.controller.spline||(t.controller.spline=t.params.loop?new i(t.slidesGrid,e.slidesGrid):new i(t.snapGrid,e.snapGrid))}(e),n=-t.controller.spline.interpolate(-s)),n&&"container"!==t.params.controller.by||(r=(e.maxTranslate()-e.minTranslate())/(t.maxTranslate()-t.minTranslate()),n=(s-t.minTranslate())*r+e.minTranslate()),t.params.controller.inverse&&(n=e.maxTranslate()-n),e.updateProgress(n),e.setTranslate(n,t),e.updateActiveIndex(),e.updateSlidesClasses()}if(Array.isArray(a))for(let e=0;e{s.updateAutoHeight()})),s.$wrapperEl.transitionEnd((()=>{i&&(s.params.loop&&"slide"===t.params.controller.by&&s.loopFix(),s.transitionEnd())})))}if(Array.isArray(i))for(r=0;r{n(e),"BUTTON"!==e[0].tagName&&(o(e,"button"),e.on("keydown",m)),p(e,s),function(e,t){e.attr("aria-controls",t)}(e,t)},w=()=>{t.a11y.clicked=!0},b=()=>{requestAnimationFrame((()=>{requestAnimationFrame((()=>{t.a11y.clicked=!1}))}))},x=e=>{if(t.a11y.clicked)return;const s=e.target.closest(`.${t.params.slideClass}`);if(!s||!t.slides.includes(s))return;const a=t.slides.indexOf(s)===t.activeIndex,i=t.params.watchSlidesProgress&&t.visibleSlides&&t.visibleSlides.includes(s);a||i||(t.isHorizontal()?t.el.scrollLeft=0:t.el.scrollTop=0,t.slideTo(t.slides.indexOf(s),0))},y=()=>{const e=t.params.a11y;e.itemRoleDescriptionMessage&&c(d(t.slides),e.itemRoleDescriptionMessage),e.slideRole&&o(d(t.slides),e.slideRole);const s=t.params.loop?t.slides.filter((e=>!e.classList.contains(t.params.slideDuplicateClass))).length:t.slides.length;e.slideLabelMessage&&t.slides.each(((a,i)=>{const r=d(a),n=t.params.loop?parseInt(r.attr("data-swiper-slide-index"),10):i;p(r,e.slideLabelMessage.replace(/\{\{index\}\}/,n+1).replace(/\{\{slidesLength\}\}/,s))}))},E=()=>{const e=t.params.a11y;t.$el.append(i);const s=t.$el;e.containerRoleDescriptionMessage&&c(s,e.containerRoleDescriptionMessage),e.containerMessage&&p(s,e.containerMessage);const a=t.$wrapperEl,r=e.id||a.attr("id")||`swiper-wrapper-${n=16,void 0===n&&(n=16),"x".repeat(n).replace(/x/g,(()=>Math.round(16*Math.random()).toString(16)))}`;var n;const l=t.params.autoplay&&t.params.autoplay.enabled?"off":"polite";var o;let d,u;o=r,a.attr("id",o),function(e,t){e.attr("aria-live",t)}(a,l),y(),t.navigation&&t.navigation.$nextEl&&(d=t.navigation.$nextEl),t.navigation&&t.navigation.$prevEl&&(u=t.navigation.$prevEl),d&&d.length&&v(d,r,e.nextSlideMessage),u&&u.length&&v(u,r,e.prevSlideMessage),g()&&t.pagination.$el.on("keydown",U(t.params.pagination.bulletClass),m),t.$el.on("focus",x,!0),t.$el.on("pointerdown",w,!0),t.$el.on("pointerup",b,!0)};a("beforeInit",(()=>{i=d(``)})),a("afterInit",(()=>{t.params.a11y.enabled&&E()})),a("slidesLengthChange snapGridLengthChange slidesGridLengthChange",(()=>{t.params.a11y.enabled&&y()})),a("fromEdge toEdge afterInit lock unlock",(()=>{t.params.a11y.enabled&&function(){if(t.params.loop||t.params.rewind||!t.navigation)return;const{$nextEl:e,$prevEl:s}=t.navigation;s&&s.length>0&&(t.isBeginning?(u(s),l(s)):(h(s),n(s))),e&&e.length>0&&(t.isEnd?(u(e),l(e)):(h(e),n(e)))}()})),a("paginationUpdate",(()=>{t.params.a11y.enabled&&function(){const e=t.params.a11y;f()&&t.pagination.bullets.each((s=>{const a=d(s);t.params.pagination.clickable&&(n(a),t.params.pagination.renderBullet||(o(a,"button"),p(a,e.paginationBulletMessage.replace(/\{\{index\}\}/,a.index()+1)))),a.is(`.${t.params.pagination.bulletActiveClass}`)?a.attr("aria-current","true"):a.removeAttr("aria-current")}))}()})),a("destroy",(()=>{t.params.a11y.enabled&&function(){let e,s;i&&i.length>0&&i.remove(),t.navigation&&t.navigation.$nextEl&&(e=t.navigation.$nextEl),t.navigation&&t.navigation.$prevEl&&(s=t.navigation.$prevEl),e&&e.off("keydown",m),s&&s.off("keydown",m),g()&&t.pagination.$el.off("keydown",U(t.params.pagination.bulletClass),m),t.$el.off("focus",x,!0),t.$el.off("pointerdown",w,!0),t.$el.off("pointerup",b,!0)}()}))},function(e){let{swiper:t,extendParams:s,on:a}=e;s({history:{enabled:!1,root:"",replaceState:!1,key:"slides",keepQuery:!1}});let i=!1,n={};const l=e=>e.toString().replace(/\s+/g,"-").replace(/[^\w-]+/g,"").replace(/--+/g,"-").replace(/^-+/,"").replace(/-+$/,""),o=e=>{const t=r();let s;s=e?new URL(e):t.location;const a=s.pathname.slice(1).split("/").filter((e=>""!==e)),i=a.length;return{key:a[i-2],value:a[i-1]}},d=(e,s)=>{const a=r();if(!i||!t.params.history.enabled)return;let n;n=t.params.url?new URL(t.params.url):a.location;const o=t.slides.eq(s);let d=l(o.attr("data-history"));if(t.params.history.root.length>0){let s=t.params.history.root;"/"===s[s.length-1]&&(s=s.slice(0,s.length-1)),d=`${s}/${e}/${d}`}else n.pathname.includes(e)||(d=`${e}/${d}`);t.params.history.keepQuery&&(d+=n.search);const c=a.history.state;c&&c.value===d||(t.params.history.replaceState?a.history.replaceState({value:d},null,d):a.history.pushState({value:d},null,d))},c=(e,s,a)=>{if(s)for(let i=0,r=t.slides.length;i{n=o(t.params.url),c(t.params.speed,n.value,!1)};a("init",(()=>{t.params.history.enabled&&(()=>{const e=r();if(t.params.history){if(!e.history||!e.history.pushState)return t.params.history.enabled=!1,void(t.params.hashNavigation.enabled=!0);i=!0,n=o(t.params.url),(n.key||n.value)&&(c(0,n.value,t.params.runCallbacksOnInit),t.params.history.replaceState||e.addEventListener("popstate",p))}})()})),a("destroy",(()=>{t.params.history.enabled&&(()=>{const e=r();t.params.history.replaceState||e.removeEventListener("popstate",p)})()})),a("transitionEnd _freeModeNoMomentumRelease",(()=>{i&&d(t.params.history.key,t.activeIndex)})),a("slideChange",(()=>{i&&t.params.cssMode&&d(t.params.history.key,t.activeIndex)}))},function(e){let{swiper:t,extendParams:s,emit:i,on:n}=e,l=!1;const o=a(),c=r();s({hashNavigation:{enabled:!1,replaceState:!1,watchState:!1}});const p=()=>{i("hashChange");const e=o.location.hash.replace("#","");if(e!==t.slides.eq(t.activeIndex).attr("data-hash")){const s=t.$wrapperEl.children(`.${t.params.slideClass}[data-hash="${e}"]`).index();if(void 0===s)return;t.slideTo(s)}},u=()=>{if(l&&t.params.hashNavigation.enabled)if(t.params.hashNavigation.replaceState&&c.history&&c.history.replaceState)c.history.replaceState(null,null,`#${t.slides.eq(t.activeIndex).attr("data-hash")}`||""),i("hashSet");else{const e=t.slides.eq(t.activeIndex),s=e.attr("data-hash")||e.attr("data-history");o.location.hash=s||"",i("hashSet")}};n("init",(()=>{t.params.hashNavigation.enabled&&(()=>{if(!t.params.hashNavigation.enabled||t.params.history&&t.params.history.enabled)return;l=!0;const e=o.location.hash.replace("#","");if(e){const s=0;for(let a=0,i=t.slides.length;a{t.params.hashNavigation.enabled&&t.params.hashNavigation.watchState&&d(c).off("hashchange",p)})),n("transitionEnd _freeModeNoMomentumRelease",(()=>{l&&u()})),n("slideChange",(()=>{l&&t.params.cssMode&&u()}))},function(e){let t,{swiper:s,extendParams:i,on:r,emit:n}=e;function l(){if(!s.size)return s.autoplay.running=!1,void(s.autoplay.paused=!1);const e=s.slides.eq(s.activeIndex);let a=s.params.autoplay.delay;e.attr("data-swiper-autoplay")&&(a=e.attr("data-swiper-autoplay")||s.params.autoplay.delay),clearTimeout(t),t=p((()=>{let e;s.params.autoplay.reverseDirection?s.params.loop?(s.loopFix(),e=s.slidePrev(s.params.speed,!0,!0),n("autoplay")):s.isBeginning?s.params.autoplay.stopOnLastSlide?d():(e=s.slideTo(s.slides.length-1,s.params.speed,!0,!0),n("autoplay")):(e=s.slidePrev(s.params.speed,!0,!0),n("autoplay")):s.params.loop?(s.loopFix(),e=s.slideNext(s.params.speed,!0,!0),n("autoplay")):s.isEnd?s.params.autoplay.stopOnLastSlide?d():(e=s.slideTo(0,s.params.speed,!0,!0),n("autoplay")):(e=s.slideNext(s.params.speed,!0,!0),n("autoplay")),(s.params.cssMode&&s.autoplay.running||!1===e)&&l()}),a)}function o(){return void 0===t&&(!s.autoplay.running&&(s.autoplay.running=!0,n("autoplayStart"),l(),!0))}function d(){return!!s.autoplay.running&&(void 0!==t&&(t&&(clearTimeout(t),t=void 0),s.autoplay.running=!1,n("autoplayStop"),!0))}function c(e){s.autoplay.running&&(s.autoplay.paused||(t&&clearTimeout(t),s.autoplay.paused=!0,0!==e&&s.params.autoplay.waitForTransition?["transitionend","webkitTransitionEnd"].forEach((e=>{s.$wrapperEl[0].addEventListener(e,h)})):(s.autoplay.paused=!1,l())))}function u(){const e=a();"hidden"===e.visibilityState&&s.autoplay.running&&c(),"visible"===e.visibilityState&&s.autoplay.paused&&(l(),s.autoplay.paused=!1)}function h(e){s&&!s.destroyed&&s.$wrapperEl&&e.target===s.$wrapperEl[0]&&(["transitionend","webkitTransitionEnd"].forEach((e=>{s.$wrapperEl[0].removeEventListener(e,h)})),s.autoplay.paused=!1,s.autoplay.running?l():d())}function m(){s.params.autoplay.disableOnInteraction?d():(n("autoplayPause"),c()),["transitionend","webkitTransitionEnd"].forEach((e=>{s.$wrapperEl[0].removeEventListener(e,h)}))}function f(){s.params.autoplay.disableOnInteraction||(s.autoplay.paused=!1,n("autoplayResume"),l())}s.autoplay={running:!1,paused:!1},i({autoplay:{enabled:!1,delay:3e3,waitForTransition:!0,disableOnInteraction:!0,stopOnLastSlide:!1,reverseDirection:!1,pauseOnMouseEnter:!1}}),r("init",(()=>{if(s.params.autoplay.enabled){o();a().addEventListener("visibilitychange",u),s.params.autoplay.pauseOnMouseEnter&&(s.$el.on("mouseenter",m),s.$el.on("mouseleave",f))}})),r("beforeTransitionStart",((e,t,a)=>{s.autoplay.running&&(a||!s.params.autoplay.disableOnInteraction?s.autoplay.pause(t):d())})),r("sliderFirstMove",(()=>{s.autoplay.running&&(s.params.autoplay.disableOnInteraction?d():c())})),r("touchEnd",(()=>{s.params.cssMode&&s.autoplay.paused&&!s.params.autoplay.disableOnInteraction&&l()})),r("destroy",(()=>{s.$el.off("mouseenter",m),s.$el.off("mouseleave",f),s.autoplay.running&&d();a().removeEventListener("visibilitychange",u)})),Object.assign(s.autoplay,{pause:c,run:l,start:o,stop:d})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({thumbs:{swiper:null,multipleActiveThumbs:!0,autoScrollOffset:0,slideThumbActiveClass:"swiper-slide-thumb-active",thumbsContainerClass:"swiper-thumbs"}});let i=!1,r=!1;function n(){const e=t.thumbs.swiper;if(!e||e.destroyed)return;const s=e.clickedIndex,a=e.clickedSlide;if(a&&d(a).hasClass(t.params.thumbs.slideThumbActiveClass))return;if(null==s)return;let i;if(i=e.params.loop?parseInt(d(e.clickedSlide).attr("data-swiper-slide-index"),10):s,t.params.loop){let e=t.activeIndex;t.slides.eq(e).hasClass(t.params.slideDuplicateClass)&&(t.loopFix(),t._clientLeft=t.$wrapperEl[0].clientLeft,e=t.activeIndex);const s=t.slides.eq(e).prevAll(`[data-swiper-slide-index="${i}"]`).eq(0).index(),a=t.slides.eq(e).nextAll(`[data-swiper-slide-index="${i}"]`).eq(0).index();i=void 0===s?a:void 0===a?s:a-e1&&!t.params.centeredSlides&&(i=t.params.slidesPerView),t.params.thumbs.multipleActiveThumbs||(i=1),i=Math.floor(i),s.slides.removeClass(r),s.params.loop||s.params.virtual&&s.params.virtual.enabled)for(let e=0;e1?a:o:a-ot.previousIndex?"next":"prev"}else i=t.realIndex,r=i>t.previousIndex?"next":"prev";l&&(i+="next"===r?n:-1*n),s.visibleSlidesIndexes&&s.visibleSlidesIndexes.indexOf(i)<0&&(s.params.centeredSlides?i=i>o?i-Math.floor(a/2)+1:i+Math.floor(a/2)-1:i>o&&s.params.slidesPerGroup,s.slideTo(i,e?0:void 0))}}t.thumbs={swiper:null},a("beforeInit",(()=>{const{thumbs:e}=t.params;e&&e.swiper&&(l(),o(!0))})),a("slideChange update resize observerUpdate",(()=>{o()})),a("setTransition",((e,s)=>{const a=t.thumbs.swiper;a&&!a.destroyed&&a.setTransition(s)})),a("beforeDestroy",(()=>{const e=t.thumbs.swiper;e&&!e.destroyed&&r&&e.destroy()})),Object.assign(t.thumbs,{init:l,update:o})},function(e){let{swiper:t,extendParams:s,emit:a,once:i}=e;s({freeMode:{enabled:!1,momentum:!0,momentumRatio:1,momentumBounce:!0,momentumBounceRatio:1,momentumVelocityRatio:1,sticky:!1,minimumVelocity:.02}}),Object.assign(t,{freeMode:{onTouchStart:function(){const e=t.getTranslate();t.setTranslate(e),t.setTransition(0),t.touchEventsData.velocities.length=0,t.freeMode.onTouchEnd({currentPos:t.rtl?t.translate:-t.translate})},onTouchMove:function(){const{touchEventsData:e,touches:s}=t;0===e.velocities.length&&e.velocities.push({position:s[t.isHorizontal()?"startX":"startY"],time:e.touchStartTime}),e.velocities.push({position:s[t.isHorizontal()?"currentX":"currentY"],time:u()})},onTouchEnd:function(e){let{currentPos:s}=e;const{params:r,$wrapperEl:n,rtlTranslate:l,snapGrid:o,touchEventsData:d}=t,c=u()-d.touchStartTime;if(s<-t.minTranslate())t.slideTo(t.activeIndex);else if(s>-t.maxTranslate())t.slides.length1){const e=d.velocities.pop(),s=d.velocities.pop(),a=e.position-s.position,i=e.time-s.time;t.velocity=a/i,t.velocity/=2,Math.abs(t.velocity)150||u()-e.time>300)&&(t.velocity=0)}else t.velocity=0;t.velocity*=r.freeMode.momentumVelocityRatio,d.velocities.length=0;let e=1e3*r.freeMode.momentumRatio;const s=t.velocity*e;let c=t.translate+s;l&&(c=-c);let p,h=!1;const m=20*Math.abs(t.velocity)*r.freeMode.momentumBounceRatio;let f;if(ct.minTranslate())r.freeMode.momentumBounce?(c-t.minTranslate()>m&&(c=t.minTranslate()+m),p=t.minTranslate(),h=!0,d.allowMomentumBounce=!0):c=t.minTranslate(),r.loop&&r.centeredSlides&&(f=!0);else if(r.freeMode.sticky){let e;for(let t=0;t-c){e=t;break}c=Math.abs(o[e]-c){t.loopFix()})),0!==t.velocity){if(e=l?Math.abs((-c-t.translate)/t.velocity):Math.abs((c-t.translate)/t.velocity),r.freeMode.sticky){const s=Math.abs((l?-c:c)-t.translate),a=t.slidesSizesGrid[t.activeIndex];e=s{t&&!t.destroyed&&d.allowMomentumBounce&&(a("momentumBounce"),t.setTransition(r.speed),setTimeout((()=>{t.setTranslate(p),n.transitionEnd((()=>{t&&!t.destroyed&&t.transitionEnd()}))}),0))}))):t.velocity?(a("_freeModeNoMomentumRelease"),t.updateProgress(c),t.setTransition(e),t.setTranslate(c),t.transitionStart(!0,t.swipeDirection),t.animating||(t.animating=!0,n.transitionEnd((()=>{t&&!t.destroyed&&t.transitionEnd()})))):t.updateProgress(c),t.updateActiveIndex(),t.updateSlidesClasses()}else{if(r.freeMode.sticky)return void t.slideToClosest();r.freeMode&&a("_freeModeNoMomentumRelease")}(!r.freeMode.momentum||c>=r.longSwipesMs)&&(t.updateProgress(),t.updateActiveIndex(),t.updateSlidesClasses())}}}})},function(e){let t,s,a,{swiper:i,extendParams:r}=e;r({grid:{rows:1,fill:"column"}}),i.grid={initSlides:e=>{const{slidesPerView:r}=i.params,{rows:n,fill:l}=i.params.grid;s=t/n,a=Math.floor(e/n),t=Math.floor(e/n)===e/n?e:Math.ceil(e/n)*n,"auto"!==r&&"row"===l&&(t=Math.max(t,r*n))},updateSlide:(e,r,n,l)=>{const{slidesPerGroup:o,spaceBetween:d}=i.params,{rows:c,fill:p}=i.params.grid;let u,h,m;if("row"===p&&o>1){const s=Math.floor(e/(o*c)),a=e-c*o*s,i=0===s?o:Math.min(Math.ceil((n-s*c*o)/c),o);m=Math.floor(a/i),h=a-m*i+s*o,u=h+m*t/c,r.css({"-webkit-order":u,order:u})}else"column"===p?(h=Math.floor(e/c),m=e-h*c,(h>a||h===a&&m===c-1)&&(m+=1,m>=c&&(m=0,h+=1))):(m=Math.floor(e/s),h=e-m*s);r.css(l("margin-top"),0!==m?d&&`${d}px`:"")},updateWrapperSize:(e,s,a)=>{const{spaceBetween:r,centeredSlides:n,roundLengths:l}=i.params,{rows:o}=i.params.grid;if(i.virtualSize=(e+r)*t,i.virtualSize=Math.ceil(i.virtualSize/o)-r,i.$wrapperEl.css({[a("width")]:`${i.virtualSize+r}px`}),n){s.splice(0,s.length);const e=[];for(let t=0;t{const{slides:e}=t,s=t.params.fadeEffect;for(let a=0;a{const{transformEl:s}=t.params.fadeEffect;(s?t.slides.find(s):t.slides).transition(e),ae({swiper:t,duration:e,transformEl:s,allSlides:!0})},overwriteParams:()=>({slidesPerView:1,slidesPerGroup:1,watchSlidesProgress:!0,spaceBetween:0,virtualTranslate:!t.params.cssMode})})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({cubeEffect:{slideShadows:!0,shadow:!0,shadowOffset:20,shadowScale:.94}});const i=(e,t,s)=>{let a=s?e.find(".swiper-slide-shadow-left"):e.find(".swiper-slide-shadow-top"),i=s?e.find(".swiper-slide-shadow-right"):e.find(".swiper-slide-shadow-bottom");0===a.length&&(a=d(`
`),e.append(a)),0===i.length&&(i=d(`
`),e.append(i)),a.length&&(a[0].style.opacity=Math.max(-t,0)),i.length&&(i[0].style.opacity=Math.max(t,0))};te({effect:"cube",swiper:t,on:a,setTranslate:()=>{const{$el:e,$wrapperEl:s,slides:a,width:r,height:n,rtlTranslate:l,size:o,browser:c}=t,p=t.params.cubeEffect,u=t.isHorizontal(),h=t.virtual&&t.params.virtual.enabled;let m,f=0;p.shadow&&(u?(m=s.find(".swiper-cube-shadow"),0===m.length&&(m=d('
'),s.append(m)),m.css({height:`${r}px`})):(m=e.find(".swiper-cube-shadow"),0===m.length&&(m=d('
'),e.append(m))));for(let e=0;e-1&&(f=90*s+90*d,l&&(f=90*-s-90*d)),t.transform(v),p.slideShadows&&i(t,d,u)}if(s.css({"-webkit-transform-origin":`50% 50% -${o/2}px`,"transform-origin":`50% 50% -${o/2}px`}),p.shadow)if(u)m.transform(`translate3d(0px, ${r/2+p.shadowOffset}px, ${-r/2}px) rotateX(90deg) rotateZ(0deg) scale(${p.shadowScale})`);else{const e=Math.abs(f)-90*Math.floor(Math.abs(f)/90),t=1.5-(Math.sin(2*e*Math.PI/360)/2+Math.cos(2*e*Math.PI/360)/2),s=p.shadowScale,a=p.shadowScale/t,i=p.shadowOffset;m.transform(`scale3d(${s}, 1, ${a}) translate3d(0px, ${n/2+i}px, ${-n/2/a}px) rotateX(-90deg)`)}const g=c.isSafari||c.isWebView?-o/2:0;s.transform(`translate3d(0px,0,${g}px) rotateX(${t.isHorizontal()?0:f}deg) rotateY(${t.isHorizontal()?-f:0}deg)`),s[0].style.setProperty("--swiper-cube-translate-z",`${g}px`)},setTransition:e=>{const{$el:s,slides:a}=t;a.transition(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").transition(e),t.params.cubeEffect.shadow&&!t.isHorizontal()&&s.find(".swiper-cube-shadow").transition(e)},recreateShadows:()=>{const e=t.isHorizontal();t.slides.each((t=>{const s=Math.max(Math.min(t.progress,1),-1);i(d(t),s,e)}))},getEffectParams:()=>t.params.cubeEffect,perspective:()=>!0,overwriteParams:()=>({slidesPerView:1,slidesPerGroup:1,watchSlidesProgress:!0,resistanceRatio:0,spaceBetween:0,centeredSlides:!1,virtualTranslate:!0})})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({flipEffect:{slideShadows:!0,limitRotation:!0,transformEl:null}});const i=(e,s,a)=>{let i=t.isHorizontal()?e.find(".swiper-slide-shadow-left"):e.find(".swiper-slide-shadow-top"),r=t.isHorizontal()?e.find(".swiper-slide-shadow-right"):e.find(".swiper-slide-shadow-bottom");0===i.length&&(i=ie(a,e,t.isHorizontal()?"left":"top")),0===r.length&&(r=ie(a,e,t.isHorizontal()?"right":"bottom")),i.length&&(i[0].style.opacity=Math.max(-s,0)),r.length&&(r[0].style.opacity=Math.max(s,0))};te({effect:"flip",swiper:t,on:a,setTranslate:()=>{const{slides:e,rtlTranslate:s}=t,a=t.params.flipEffect;for(let r=0;r{const{transformEl:s}=t.params.flipEffect;(s?t.slides.find(s):t.slides).transition(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").transition(e),ae({swiper:t,duration:e,transformEl:s})},recreateShadows:()=>{const e=t.params.flipEffect;t.slides.each((s=>{const a=d(s);let r=a[0].progress;t.params.flipEffect.limitRotation&&(r=Math.max(Math.min(s.progress,1),-1)),i(a,r,e)}))},getEffectParams:()=>t.params.flipEffect,perspective:()=>!0,overwriteParams:()=>({slidesPerView:1,slidesPerGroup:1,watchSlidesProgress:!0,spaceBetween:0,virtualTranslate:!t.params.cssMode})})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({coverflowEffect:{rotate:50,stretch:0,depth:100,scale:1,modifier:1,slideShadows:!0,transformEl:null}}),te({effect:"coverflow",swiper:t,on:a,setTranslate:()=>{const{width:e,height:s,slides:a,slidesSizesGrid:i}=t,r=t.params.coverflowEffect,n=t.isHorizontal(),l=t.translate,o=n?e/2-l:s/2-l,d=n?r.rotate:-r.rotate,c=r.depth;for(let e=0,t=a.length;e0?p:0),s.length&&(s[0].style.opacity=-p>0?-p:0)}}},setTransition:e=>{const{transformEl:s}=t.params.coverflowEffect;(s?t.slides.find(s):t.slides).transition(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").transition(e)},perspective:()=>!0,overwriteParams:()=>({watchSlidesProgress:!0})})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({creativeEffect:{transformEl:null,limitProgress:1,shadowPerProgress:!1,progressMultiplier:1,perspective:!0,prev:{translate:[0,0,0],rotate:[0,0,0],opacity:1,scale:1},next:{translate:[0,0,0],rotate:[0,0,0],opacity:1,scale:1}}});const i=e=>"string"==typeof e?e:`${e}px`;te({effect:"creative",swiper:t,on:a,setTranslate:()=>{const{slides:e,$wrapperEl:s,slidesSizesGrid:a}=t,r=t.params.creativeEffect,{progressMultiplier:n}=r,l=t.params.centeredSlides;if(l){const e=a[0]/2-t.params.slidesOffsetBefore||0;s.transform(`translateX(calc(50% - ${e}px))`)}for(let s=0;s0&&(f=r.prev,m=!0),u.forEach(((e,t)=>{u[t]=`calc(${e}px + (${i(f.translate[t])} * ${Math.abs(d*n)}))`})),h.forEach(((e,t)=>{h[t]=f.rotate[t]*Math.abs(d*n)})),a[0].style.zIndex=-Math.abs(Math.round(o))+e.length;const g=u.join(", "),v=`rotateX(${h[0]}deg) rotateY(${h[1]}deg) rotateZ(${h[2]}deg)`,w=c<0?`scale(${1+(1-f.scale)*c*n})`:`scale(${1-(1-f.scale)*c*n})`,b=c<0?1+(1-f.opacity)*c*n:1-(1-f.opacity)*c*n,x=`translate3d(${g}) ${v} ${w}`;if(m&&f.shadow||!m){let e=a.children(".swiper-slide-shadow");if(0===e.length&&f.shadow&&(e=ie(r,a)),e.length){const t=r.shadowPerProgress?d*(1/r.limitProgress):d;e[0].style.opacity=Math.min(Math.max(Math.abs(t),0),1)}}const y=se(r,a);y.transform(x).css({opacity:b}),f.origin&&y.css("transform-origin",f.origin)}},setTransition:e=>{const{transformEl:s}=t.params.creativeEffect;(s?t.slides.find(s):t.slides).transition(e).find(".swiper-slide-shadow").transition(e),ae({swiper:t,duration:e,transformEl:s,allSlides:!0})},perspective:()=>t.params.creativeEffect.perspective,overwriteParams:()=>({watchSlidesProgress:!0,virtualTranslate:!t.params.cssMode})})},function(e){let{swiper:t,extendParams:s,on:a}=e;s({cardsEffect:{slideShadows:!0,transformEl:null,rotate:!0,perSlideRotate:2,perSlideOffset:8}}),te({effect:"cards",swiper:t,on:a,setTranslate:()=>{const{slides:e,activeIndex:s}=t,a=t.params.cardsEffect,{startTranslate:i,isTouched:r}=t.touchEventsData,n=t.translate;for(let l=0;l0&&c<1&&(r||t.params.cssMode)&&n-1&&(r||t.params.cssMode)&&n>i;if(b||x){const e=(1-Math.abs((Math.abs(c)-.5)/.5))**.5;g+=-28*c*e,f+=-.5*e,v+=96*e,h=-25*e*Math.abs(c)+"%"}if(u=c<0?`calc(${u}px + (${v*Math.abs(c)}%))`:c>0?`calc(${u}px + (-${v*Math.abs(c)}%))`:`${u}px`,!t.isHorizontal()){const e=h;h=u,u=e}const y=c<0?""+(1+(1-f)*c):""+(1-(1-f)*c),E=`\n translate3d(${u}, ${h}, ${m}px)\n rotateZ(${a.rotate?g:0}deg)\n scale(${y})\n `;if(a.slideShadows){let e=o.find(".swiper-slide-shadow");0===e.length&&(e=ie(a,o)),e.length&&(e[0].style.opacity=Math.min(Math.max((Math.abs(c)-.5)/.5,0),1))}o[0].style.zIndex=-Math.abs(Math.round(d))+e.length;se(a,o).transform(E)}},setTransition:e=>{const{transformEl:s}=t.params.cardsEffect;(s?t.slides.find(s):t.slides).transition(e).find(".swiper-slide-shadow").transition(e),ae({swiper:t,duration:e,transformEl:s})},perspective:()=>!0,overwriteParams:()=>({watchSlidesProgress:!0,virtualTranslate:!t.params.cssMode})})}];return V.use(re),V})); +//# sourceMappingURL=swiper-bundle.min.js.map \ No newline at end of file diff --git a/apps/smp-server/static/media/tailwind.css b/apps/smp-server/static/media/tailwind.css new file mode 100644 index 000000000..04ea69992 --- /dev/null +++ b/apps/smp-server/static/media/tailwind.css @@ -0,0 +1,3058 @@ +/* +! tailwindcss v3.3.1 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +*/ + +html { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.visible { + visibility: visible; +} + +.static { + position: static; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.-left-10 { + left: -2.5rem; +} + +.bottom-0 { + bottom: 0px; +} + +.bottom-\[80px\] { + bottom: 80px; +} + +.left-0 { + left: 0px; +} + +.left-1 { + left: 0.25rem; +} + +.left-\[-3px\] { + left: -3px; +} + +.right-0 { + right: 0px; +} + +.right-1 { + right: 0.25rem; +} + +.right-\[-6px\] { + right: -6px; +} + +.top-0 { + top: 0px; +} + +.top-10 { + top: 2.5rem; +} + +.top-11 { + top: 2.75rem; +} + +.top-\[52\%\] { + top: 52%; +} + +.top-\[66px\] { + top: 66px; +} + +.top-full { + top: 100%; +} + +.z-10 { + z-index: 10; +} + +.z-50 { + z-index: 50; +} + +.z-\[10000\] { + z-index: 10000; +} + +.z-\[10001\] { + z-index: 10001; +} + +.z-\[49\] { + z-index: 49; +} + +.float-right { + float: right; +} + +.m-1 { + margin: 0.25rem; +} + +.m-auto { + margin: auto; +} + +.\!my-4 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.mx-5 { + margin-left: 1.25rem; + margin-right: 1.25rem; +} + +.my-10 { + margin-top: 2.5rem; + margin-bottom: 2.5rem; +} + +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.my-5 { + margin-top: 1.25rem; + margin-bottom: 1.25rem; +} + +.my-\[40px\] { + margin-top: 40px; + margin-bottom: 40px; +} + +.mb-10 { + margin-bottom: 2.5rem; +} + +.mb-12 { + margin-bottom: 3rem; +} + +.mb-14 { + margin-bottom: 3.5rem; +} + +.mb-16 { + margin-bottom: 4rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-20 { + margin-bottom: 5rem; +} + +.mb-24 { + margin-bottom: 6rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-32 { + margin-bottom: 8rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mb-7 { + margin-bottom: 1.75rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + +.mb-9 { + margin-bottom: 2.25rem; +} + +.mb-\[11px\] { + margin-bottom: 11px; +} + +.mb-\[12px\] { + margin-bottom: 12px; +} + +.mb-\[16px\] { + margin-bottom: 16px; +} + +.mb-\[20px\] { + margin-bottom: 20px; +} + +.mb-\[24px\] { + margin-bottom: 24px; +} + +.mb-\[28px\] { + margin-bottom: 28px; +} + +.mb-\[30px\] { + margin-bottom: 30px; +} + +.mb-\[32px\] { + margin-bottom: 32px; +} + +.mb-\[36px\] { + margin-bottom: 36px; +} + +.mb-\[40px\] { + margin-bottom: 40px; +} + +.mb-\[46px\] { + margin-bottom: 46px; +} + +.mb-\[50px\] { + margin-bottom: 50px; +} + +.mb-\[54px\] { + margin-bottom: 54px; +} + +.mb-\[62px\] { + margin-bottom: 62px; +} + +.mb-\[74px\] { + margin-bottom: 74px; +} + +.mb-\[75px\] { + margin-bottom: 75px; +} + +.mb-\[76px\] { + margin-bottom: 76px; +} + +.mb-\[80px\] { + margin-bottom: 80px; +} + +.ml-\[-15px\] { + margin-left: -15px; +} + +.ml-\[-6px\] { + margin-left: -6px; +} + +.mr-\[-4px\] { + margin-right: -4px; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-10 { + margin-top: 2.5rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-6 { + margin-top: 1.5rem; +} + +.mt-\[-100px\] { + margin-top: -100px; +} + +.mt-\[10px\] { + margin-top: 10px; +} + +.mt-\[14px\] { + margin-top: 14px; +} + +.mt-\[30px\] { + margin-top: 30px; +} + +.mt-\[60px\] { + margin-top: 60px; +} + +.mt-\[66px\] { + margin-top: 66px; +} + +.mt-\[74px\] { + margin-top: 74px; +} + +.mt-\[8px\] { + margin-top: 8px; +} + +.mt-auto { + margin-top: auto; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + +.flex { + display: flex; +} + +.table { + display: table; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-1 { + height: 0.25rem; +} + +.h-11 { + height: 2.75rem; +} + +.h-4 { + height: 1rem; +} + +.h-6 { + height: 1.5rem; +} + +.h-\[110px\] { + height: 110px; +} + +.h-\[131px\] { + height: 131px; +} + +.h-\[138px\] { + height: 138px; +} + +.h-\[180px\] { + height: 180px; +} + +.h-\[1px\] { + height: 1px; +} + +.h-\[215px\] { + height: 215px; +} + +.h-\[322px\] { + height: 322px; +} + +.h-\[32px\] { + height: 32px; +} + +.h-\[340px\] { + height: 340px; +} + +.h-\[34px\] { + height: 34px; +} + +.h-\[36px\] { + height: 36px; +} + +.h-\[40px\] { + height: 40px; +} + +.h-\[44px\] { + height: 44px; +} + +.h-\[46px\] { + height: 46px; +} + +.h-\[480px\] { + height: 480px; +} + +.h-\[52px\] { + height: 52px; +} + +.h-\[55\%\] { + height: 55%; +} + +.h-\[66px\] { + height: 66px; +} + +.h-\[80px\] { + height: 80px; +} + +.h-\[calc\(100\%-81\.42px\)\] { + height: calc(100% - 81.42px); +} + +.h-\[inherit\] { + height: inherit; +} + +.h-auto { + height: auto; +} + +.h-fit { + height: -moz-fit-content; + height: fit-content; +} + +.h-full { + height: 100%; +} + +.h-screen { + height: 100vh; +} + +.max-h-\[226px\] { + max-height: 226px; +} + +.max-h-\[50px\] { + max-height: 50px; +} + +.min-h-\[131px\] { + min-height: 131px; +} + +.min-h-\[200px\] { + min-height: 200px; +} + +.min-h-\[620px\] { + min-height: 620px; +} + +.min-h-\[inherit\] { + min-height: inherit; +} + +.w-4 { + width: 1rem; +} + +.w-8 { + width: 2rem; +} + +.w-\[175px\] { + width: 175px; +} + +.w-\[235px\] { + width: 235px; +} + +.w-\[238px\] { + width: 238px; +} + +.w-\[36px\] { + width: 36px; +} + +.w-\[44px\] { + width: 44px; +} + +.w-\[630px\] { + width: 630px; +} + +.w-\[inherit\] { + width: inherit; +} + +.w-auto { + width: auto; +} + +.w-full { + width: 100%; +} + +.min-w-\[152px\] { + min-width: 152px; +} + +.min-w-\[170px\] { + min-width: 170px; +} + +.min-w-\[180px\] { + min-width: 180px; +} + +.min-w-\[210px\] { + min-width: 210px; +} + +.min-w-\[300px\] { + min-width: 300px; +} + +.max-w-\[223px\] { + max-width: 223px; +} + +.max-w-\[230px\] { + max-width: 230px; +} + +.max-w-\[234px\] { + max-width: 234px; +} + +.max-w-\[240px\] { + max-width: 240px; +} + +.max-w-\[265px\] { + max-width: 265px; +} + +.max-w-\[280px\] { + max-width: 280px; +} + +.max-w-\[294px\] { + max-width: 294px; +} + +.max-w-\[400px\] { + max-width: 400px; +} + +.max-w-\[448px\] { + max-width: 448px; +} + +.max-w-\[468px\] { + max-width: 468px; +} + +.max-w-\[475px\] { + max-width: 475px; +} + +.max-w-\[500px\] { + max-width: 500px; +} + +.max-w-\[540px\] { + max-width: 540px; +} + +.max-w-\[541px\] { + max-width: 541px; +} + +.max-w-\[602px\] { + max-width: 602px; +} + +.max-w-\[617px\] { + max-width: 617px; +} + +.max-w-\[900px\] { + max-width: 900px; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-\[1\] { + flex: 1; +} + +.flex-\[2\.5\] { + flex: 2.5; +} + +.border-separate { + border-collapse: separate; +} + +.border-spacing-x-5 { + --tw-border-spacing-x: 1.25rem; + border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y); +} + +.border-spacing-y-2 { + --tw-border-spacing-y: 0.5rem; + border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y); +} + +.translate-x-\[-30\%\] { + --tw-translate-x: -30%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-y-\[-30\%\] { + --tw-translate-y: -30%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.scale-100 { + --tw-scale-x: 1; + --tw-scale-y: 1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.cursor-pointer { + cursor: pointer; +} + +.resize { + resize: both; +} + +.list-inside { + list-style-position: inside; +} + +.list-decimal { + list-style-type: decimal; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.flex-row { + flex-direction: row; +} + +.flex-col { + flex-direction: column; +} + +.flex-col-reverse { + flex-direction: column-reverse; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.justify-around { + justify-content: space-around; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-10 { + gap: 2.5rem; +} + +.gap-12 { + gap: 3rem; +} + +.gap-16 { + gap: 4rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-4 { + gap: 1rem; +} + +.gap-5 { + gap: 1.25rem; +} + +.gap-6 { + gap: 1.5rem; +} + +.gap-\[150px\] { + gap: 150px; +} + +.gap-\[20px\] { + gap: 20px; +} + +.gap-\[50px\] { + gap: 50px; +} + +.gap-x-10 { + -moz-column-gap: 2.5rem; + column-gap: 2.5rem; +} + +.gap-y-32 { + row-gap: 8rem; +} + +.self-center { + align-self: center; +} + +.self-stretch { + align-self: stretch; +} + +.overflow-auto { + overflow: auto; +} + +.overflow-hidden { + overflow: hidden; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.break-words { + overflow-wrap: break-word; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-\[12px\] { + border-radius: 12px; +} + +.rounded-\[20px\] { + border-radius: 20px; +} + +.rounded-\[30px\] { + border-radius: 30px; +} + +.rounded-\[34px\] { + border-radius: 34px; +} + +.rounded-\[4px\] { + border-radius: 4px; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.rounded-b-\[20px\] { + border-bottom-right-radius: 20px; + border-bottom-left-radius: 20px; +} + +.border { + border-width: 1px; +} + +.border-\[1px\] { + border-width: 1px; +} + +.border-solid { + border-style: solid; +} + +.border-\[\#0053D0\] { + --tw-border-opacity: 1; + border-color: rgb(0 83 208 / var(--tw-border-opacity)); +} + +.border-\[\#859096\] { + --tw-border-opacity: 1; + border-color: rgb(133 144 150 / var(--tw-border-opacity)); +} + +.border-\[\#A8B0B4\] { + --tw-border-opacity: 1; + border-color: rgb(168 176 180 / var(--tw-border-opacity)); +} + +.border-primary-light { + --tw-border-opacity: 1; + border-color: rgb(0 83 208 / var(--tw-border-opacity)); +} + +.border-opacity-60 { + --tw-border-opacity: 0.6; +} + +.bg-\[\#0053D0\] { + --tw-bg-opacity: 1; + background-color: rgb(0 83 208 / var(--tw-bg-opacity)); +} + +.bg-\[\#0197FF\] { + --tw-bg-opacity: 1; + background-color: rgb(1 151 255 / var(--tw-bg-opacity)); +} + +.bg-\[\#17203D\] { + --tw-bg-opacity: 1; + background-color: rgb(23 32 61 / var(--tw-bg-opacity)); +} + +.bg-\[\#48F6C2\] { + --tw-bg-opacity: 1; + background-color: rgb(72 246 194 / var(--tw-bg-opacity)); +} + +.bg-\[\#D9E7ED\] { + --tw-bg-opacity: 1; + background-color: rgb(217 231 237 / var(--tw-bg-opacity)); +} + +.bg-\[\#D9ECFF\] { + --tw-bg-opacity: 1; + background-color: rgb(217 236 255 / var(--tw-bg-opacity)); +} + +.bg-\[\#F0F1F2\] { + --tw-bg-opacity: 1; + background-color: rgb(240 241 242 / var(--tw-bg-opacity)); +} + +.bg-\[\#F3F6F7\] { + --tw-bg-opacity: 1; + background-color: rgb(243 246 247 / var(--tw-bg-opacity)); +} + +.bg-\[\#fff\] { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(13 14 18 / var(--tw-bg-opacity)); +} + +.bg-card-bg-light { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-card-desc-bg-light { + --tw-bg-opacity: 1; + background-color: rgb(217 231 237 / var(--tw-bg-opacity)); +} + +.bg-primary-bg-light { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-primary-light { + --tw-bg-opacity: 1; + background-color: rgb(0 83 208 / var(--tw-bg-opacity)); +} + +.bg-secondary-bg-light { + --tw-bg-opacity: 1; + background-color: rgb(243 246 247 / var(--tw-bg-opacity)); +} + +.bg-transparent { + background-color: transparent; +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-yellow-200 { + --tw-bg-opacity: 1; + background-color: rgb(254 240 138 / var(--tw-bg-opacity)); +} + +.fill-\[rgb\(60\2c 60\2c 60\)\] { + fill: rgb(60,60,60); +} + +.fill-black { + fill: #0D0E12; +} + +.fill-grey-black { + fill: #3F484B; +} + +.fill-primary-light { + fill: #0053D0; +} + +.p-1 { + padding: 0.25rem; +} + +.p-2 { + padding: 0.5rem; +} + +.p-3 { + padding: 0.75rem; +} + +.p-4 { + padding: 1rem; +} + +.p-6 { + padding: 1.5rem; +} + +.\!py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; +} + +.px-0 { + padding-left: 0px; + padding-right: 0px; +} + +.px-20 { + padding-left: 5rem; + padding-right: 5rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} + +.px-\[20px\] { + padding-left: 20px; + padding-right: 20px; +} + +.px-\[34px\] { + padding-left: 34px; + padding-right: 34px; +} + +.py-10 { + padding-top: 2.5rem; + padding-bottom: 2.5rem; +} + +.py-12 { + padding-top: 3rem; + padding-bottom: 3rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.py-5 { + padding-top: 1.25rem; + padding-bottom: 1.25rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.py-\[20px\] { + padding-top: 20px; + padding-bottom: 20px; +} + +.py-\[24px\] { + padding-top: 24px; + padding-bottom: 24px; +} + +.py-\[50px\] { + padding-top: 50px; + padding-bottom: 50px; +} + +.py-\[70px\] { + padding-top: 70px; + padding-bottom: 70px; +} + +.py-\[75px\] { + padding-top: 75px; + padding-bottom: 75px; +} + +.py-\[90px\] { + padding-top: 90px; + padding-bottom: 90px; +} + +.py-\[95px\] { + padding-top: 95px; + padding-bottom: 95px; +} + +.pb-4 { + padding-bottom: 1rem; +} + +.pb-8 { + padding-bottom: 2rem; +} + +.pb-\[90px\] { + padding-bottom: 90px; +} + +.pr-10 { + padding-right: 2.5rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.pt-5 { + padding-top: 1.25rem; +} + +.pt-\[106px\] { + padding-top: 106px; +} + +.pt-\[30px\] { + padding-top: 30px; +} + +.pt-\[40px\] { + padding-top: 40px; +} + +.pt-\[66px\] { + padding-top: 66px; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-\[12px\] { + font-size: 12px; +} + +.text-\[14px\] { + font-size: 14px; +} + +.text-\[16px\] { + font-size: 16px; +} + +.text-\[18px\] { + font-size: 18px; +} + +.text-\[20px\] { + font-size: 20px; +} + +.text-\[24px\] { + font-size: 24px; +} + +.text-\[25px\] { + font-size: 25px; +} + +.text-\[28px\] { + font-size: 28px; +} + +.text-\[30px\] { + font-size: 30px; +} + +.text-\[35px\] { + font-size: 35px; +} + +.text-\[38px\] { + font-size: 38px; +} + +.text-\[42px\] { + font-size: 42px; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.font-bold { + font-weight: 700; +} + +.font-light { + font-weight: 300; +} + +.font-medium { + font-weight: 500; +} + +.font-normal { + font-weight: 400; +} + +.capitalize { + text-transform: capitalize; +} + +.italic { + font-style: italic; +} + +.leading-6 { + line-height: 1.5rem; +} + +.leading-\[19px\] { + line-height: 19px; +} + +.leading-\[24px\] { + line-height: 24px; +} + +.leading-\[26px\] { + line-height: 26px; +} + +.leading-\[28px\] { + line-height: 28px; +} + +.leading-\[33px\] { + line-height: 33px; +} + +.leading-\[34px\] { + line-height: 34px; +} + +.leading-\[36px\] { + line-height: 36px; +} + +.leading-\[43px\] { + line-height: 43px; +} + +.leading-\[45px\] { + line-height: 45px; +} + +.leading-\[46px\] { + line-height: 46px; +} + +.tracking-\[0\.01em\] { + letter-spacing: 0.01em; +} + +.tracking-\[0\.02em\] { + letter-spacing: 0.02em; +} + +.tracking-\[0\.03em\] { + letter-spacing: 0.03em; +} + +.tracking-\[0\.04em\] { + letter-spacing: 0.04em; +} + +.tracking-\[0\.06em\] { + letter-spacing: 0.06em; +} + +.\!text-\[rgb\(60\2c 60\2c 60\)\] { + --tw-text-opacity: 1 !important; + color: rgb(60 60 60 / var(--tw-text-opacity)) !important; +} + +.\!text-primary-pressed-light { + --tw-text-opacity: 1 !important; + color: rgb(64 122 210 / var(--tw-text-opacity)) !important; +} + +.text-\[\#606C71\] { + --tw-text-opacity: 1; + color: rgb(96 108 113 / var(--tw-text-opacity)); +} + +.text-\[\#A8B0B4\] { + --tw-text-opacity: 1; + color: rgb(168 176 180 / var(--tw-text-opacity)); +} + +.text-\[\#DD0000\] { + --tw-text-opacity: 1; + color: rgb(221 0 0 / var(--tw-text-opacity)); +} + +.text-active-blue { + --tw-text-opacity: 1; + color: rgb(1 151 255 / var(--tw-text-opacity)); +} + +.text-black { + --tw-text-opacity: 1; + color: rgb(13 14 18 / var(--tw-text-opacity)); +} + +.text-grey-black { + --tw-text-opacity: 1; + color: rgb(63 72 75 / var(--tw-text-opacity)); +} + +.text-primary-light { + --tw-text-opacity: 1; + color: rgb(0 83 208 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.underline { + text-decoration-line: underline; +} + +.\!no-underline { + text-decoration-line: none !important; +} + +.no-underline { + text-decoration-line: none; +} + +.underline-offset-2 { + text-underline-offset: 2px; +} + +.underline-offset-4 { + text-underline-offset: 4px; +} + +.opacity-100 { + opacity: 1; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-\[0_0_3px_rgb\(60_72_88_\/_15\%\)\] { + --tw-shadow: 0 0 3px rgb(60 72 88 / 15%); + --tw-shadow-colored: 0 0 3px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-\[0px_20px_30px_rgba\(0\2c 0\2c 0\2c 0\.12\)\] { + --tw-shadow: 0px 20px 30px rgba(0,0,0,0.12); + --tw-shadow-colored: 0px 20px 30px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-\[0px_3px_12px_rgba\(0\2c 0\2c 0\2c 0\.2\)\] { + --tw-shadow: 0px 3px 12px rgba(0,0,0,0.2); + --tw-shadow-colored: 0px 3px 12px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-\[0px_50px_61px_rgba\(0\2c 0\2c 0\2c 0\.12\)\] { + --tw-shadow: 0px 50px 61px rgba(0,0,0,0.12); + --tw-shadow-colored: 0px 50px 61px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-\[2px_2px_0px_\#859096\] { + --tw-shadow: 2px 2px 0px #859096; + --tw-shadow-colored: 2px 2px 0px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-\[inset_0px_2px_2px_rgba\(0\2c 0\2c 0\2c 0\.15\)\] { + --tw-shadow: inset 0px 2px 2px rgba(0,0,0,0.15); + --tw-shadow-colored: inset 0px 2px 2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.invert { + --tw-invert: invert(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.placeholder\:text-base::-moz-placeholder { + font-size: 1rem; + line-height: 1.5rem; +} + +.placeholder\:text-base::placeholder { + font-size: 1rem; + line-height: 1.5rem; +} + +.placeholder\:font-normal::-moz-placeholder { + font-weight: 400; +} + +.placeholder\:font-normal::placeholder { + font-weight: 400; +} + +.placeholder\:tracking-\[0\.01em\]::-moz-placeholder { + letter-spacing: 0.01em; +} + +.placeholder\:tracking-\[0\.01em\]::placeholder { + letter-spacing: 0.01em; +} + +.placeholder\:text-grey-black::-moz-placeholder { + --tw-text-opacity: 1; + color: rgb(63 72 75 / var(--tw-text-opacity)); +} + +.placeholder\:text-grey-black::placeholder { + --tw-text-opacity: 1; + color: rgb(63 72 75 / var(--tw-text-opacity)); +} + +.before\:absolute::before { + content: var(--tw-content); + position: absolute; +} + +.before\:h-full::before { + content: var(--tw-content); + height: 100%; +} + +.before\:w-full::before { + content: var(--tw-content); + width: 100%; +} + +.before\:bg-black::before { + content: var(--tw-content); + --tw-bg-opacity: 1; + background-color: rgb(13 14 18 / var(--tw-bg-opacity)); +} + +.before\:bg-secondary-bg-light::before { + content: var(--tw-content); + --tw-bg-opacity: 1; + background-color: rgb(243 246 247 / var(--tw-bg-opacity)); +} + +.before\:opacity-90::before { + content: var(--tw-content); + opacity: 0.9; +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +:is([dir="ltr"] .ltr\:ml-8) { + margin-left: 2rem; +} + +:is([dir="ltr"] .ltr\:mr-auto) { + margin-right: auto; +} + +:is([dir="ltr"] .ltr\:\!hidden) { + display: none !important; +} + +:is([dir="ltr"] .ltr\:rounded-l-\[34px\]) { + border-top-left-radius: 34px; + border-bottom-left-radius: 34px; +} + +:is([dir="ltr"] .ltr\:rounded-r-\[34px\]) { + border-top-right-radius: 34px; + border-bottom-right-radius: 34px; +} + +:is([dir="rtl"] .rtl\:ml-auto) { + margin-left: auto; +} + +:is([dir="rtl"] .rtl\:mr-8) { + margin-right: 2rem; +} + +:is([dir="rtl"] .rtl\:\!hidden) { + display: none !important; +} + +:is([dir="rtl"] .rtl\:rounded-l-\[34px\]) { + border-top-left-radius: 34px; + border-bottom-left-radius: 34px; +} + +:is([dir="rtl"] .rtl\:rounded-r-\[34px\]) { + border-top-right-radius: 34px; + border-bottom-right-radius: 34px; +} + +:is([dir="rtl"] .rtl\:text-right) { + text-align: right; +} + +:is(.dark .dark\:block) { + display: block; +} + +:is(.dark .dark\:inline-block) { + display: inline-block; +} + +:is(.dark .dark\:flex) { + display: flex; +} + +:is(.dark .dark\:\!hidden) { + display: none !important; +} + +:is(.dark .dark\:hidden) { + display: none; +} + +:is(.dark .dark\:rounded-\[6px\]) { + border-radius: 6px; +} + +:is(.dark .dark\:border) { + border-width: 1px; +} + +:is(.dark .dark\:border-none) { + border-style: none; +} + +:is(.dark .dark\:border-primary-dark) { + --tw-border-opacity: 1; + border-color: rgb(112 240 249 / var(--tw-border-opacity)); +} + +:is(.dark .dark\:border-white) { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +:is(.dark .dark\:bg-\[\#0C0B13\]) { + --tw-bg-opacity: 1; + background-color: rgb(12 11 19 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-\[\#0E2B57\]) { + --tw-bg-opacity: 1; + background-color: rgb(14 43 87 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-\[\#11182F\]) { + --tw-bg-opacity: 1; + background-color: rgb(17 24 47 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-\[\#171F3A\]) { + --tw-bg-opacity: 1; + background-color: rgb(23 31 58 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-\[\#17203D\]) { + --tw-bg-opacity: 1; + background-color: rgb(23 32 61 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-\[rgba\(112\2c 240\2c 249\2c 0\.2\)\]) { + background-color: rgba(112,240,249,0.2); +} + +:is(.dark .dark\:bg-black) { + --tw-bg-opacity: 1; + background-color: rgb(13 14 18 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-card-bg-dark) { + --tw-bg-opacity: 1; + background-color: rgb(23 32 61 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-card-desc-bg-dark) { + --tw-bg-opacity: 1; + background-color: rgb(27 50 92 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-grey-black) { + --tw-bg-opacity: 1; + background-color: rgb(63 72 75 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-primary-bg-dark) { + --tw-bg-opacity: 1; + background-color: rgb(12 11 19 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-primary-dark) { + --tw-bg-opacity: 1; + background-color: rgb(112 240 249 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-secondary-bg-dark) { + --tw-bg-opacity: 1; + background-color: rgb(17 24 47 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-transparent) { + background-color: transparent; +} + +:is(.dark .dark\:bg-opacity-\[0\.2\]) { + --tw-bg-opacity: 0.2; +} + +:is(.dark .dark\:bg-gradient-radial-mobile) { + background-image: radial-gradient(77.4% 73.09% at -3.68% 100%, #17203D 0%, #0C0B13 100%); +} + +:is(.dark .dark\:fill-primary-dark) { + fill: #70F0F9; +} + +:is(.dark .dark\:fill-white) { + fill: #fff; +} + +:is(.dark .dark\:\!text-white) { + --tw-text-opacity: 1 !important; + color: rgb(255 255 255 / var(--tw-text-opacity)) !important; +} + +:is(.dark .dark\:text-\[\#70F0F9\]) { + --tw-text-opacity: 1; + color: rgb(112 240 249 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-black) { + --tw-text-opacity: 1; + color: rgb(13 14 18 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-primary-dark) { + --tw-text-opacity: 1; + color: rgb(112 240 249 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-white) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:opacity-\[0\.1\]) { + opacity: 0.1; +} + +:is(.dark .dark\:opacity-\[0\.2\]) { + opacity: 0.2; +} + +:is(.dark .dark\:shadow-none) { + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +:is(.dark .placeholder\:dark\:text-white)::-moz-placeholder { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +:is(.dark .placeholder\:dark\:text-white)::placeholder { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:before\:bg-primary-bg-dark)::before { + content: var(--tw-content); + --tw-bg-opacity: 1; + background-color: rgb(12 11 19 / var(--tw-bg-opacity)); +} + +:is(.dark .dark\:before\:bg-white)::before { + content: var(--tw-content); + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +@media (min-width: 640px) { + .sm\:block { + display: block; + } + + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sm\:flex-row { + flex-direction: row; + } + + .sm\:p-14 { + padding: 3.5rem; + } + + .sm\:px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; + } + + .sm\:px-4 { + padding-left: 1rem; + padding-right: 1rem; + } +} + +@media (min-width: 768px) { + .md\:static { + position: static; + } + + .md\:mb-0 { + margin-bottom: 0px; + } + + .md\:mb-16 { + margin-bottom: 4rem; + } + + .md\:mb-6 { + margin-bottom: 1.5rem; + } + + .md\:mb-8 { + margin-bottom: 2rem; + } + + .md\:mt-6 { + margin-top: 1.5rem; + } + + .md\:block { + display: block; + } + + .md\:inline-block { + display: inline-block; + } + + .md\:flex { + display: flex; + } + + .md\:hidden { + display: none; + } + + .md\:h-fit { + height: -moz-fit-content; + height: fit-content; + } + + .md\:max-h-\[660px\] { + max-height: 660px; + } + + .md\:min-h-fit { + min-height: -moz-fit-content; + min-height: fit-content; + } + + .md\:w-\[168px\] { + width: 168px; + } + + .md\:w-\[30\%\] { + width: 30%; + } + + .md\:w-\[70\%\] { + width: 70%; + } + + .md\:w-fit { + width: -moz-fit-content; + width: fit-content; + } + + .md\:max-w-\[1276px\] { + max-width: 1276px; + } + + .md\:max-w-\[220px\] { + max-width: 220px; + } + + .md\:max-w-\[490px\] { + max-width: 490px; + } + + .md\:flex-1 { + flex: 1 1 0%; + } + + .md\:flex-\[1\] { + flex: 1; + } + + .md\:flex-\[2\] { + flex: 2; + } + + .md\:flex-row { + flex-direction: row; + } + + .md\:flex-col-reverse { + flex-direction: column-reverse; + } + + .md\:items-start { + align-items: flex-start; + } + + .md\:items-center { + align-items: center; + } + + .md\:gap-14 { + gap: 3.5rem; + } + + .md\:gap-9 { + gap: 2.25rem; + } + + .md\:p-10 { + padding: 2.5rem; + } + + .md\:p-\[60px\] { + padding: 60px; + } + + .md\:px-0 { + padding-left: 0px; + padding-right: 0px; + } + + .md\:px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; + } + + .md\:py-16 { + padding-top: 4rem; + padding-bottom: 4rem; + } + + .md\:py-7 { + padding-top: 1.75rem; + padding-bottom: 1.75rem; + } + + .md\:py-8 { + padding-top: 2rem; + padding-bottom: 2rem; + } + + .md\:text-left { + text-align: left; + } + + .md\:text-\[20px\] { + font-size: 20px; + } + + .md\:text-\[35px\] { + font-size: 35px; + } + + .md\:text-\[38px\] { + font-size: 38px; + } + + .md\:text-\[55px\] { + font-size: 55px; + } + + .md\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + + .md\:leading-\[43px\] { + line-height: 43px; + } + + .md\:leading-\[55px\] { + line-height: 55px; + } + + .md\:leading-\[63px\] { + line-height: 63px; + } + + :is([dir="rtl"] .md\:rtl\:inline-block) { + display: inline-block; + } + + :is([dir="rtl"] .md\:rtl\:text-right) { + text-align: right; + } + + :is(.dark .dark\:md\:inline-block) { + display: inline-block; + } + + :is(.dark .md\:dark\:inline-block) { + display: inline-block; + } +} + +@media (min-width: 1024px) { + .lg\:absolute { + position: absolute; + } + + .lg\:relative { + position: relative; + } + + .lg\:top-0 { + top: 0px; + } + + .lg\:mb-0 { + margin-bottom: 0px; + } + + .lg\:mb-20 { + margin-bottom: 5rem; + } + + .lg\:mb-8 { + margin-bottom: 2rem; + } + + .lg\:mb-\[54px\] { + margin-bottom: 54px; + } + + .lg\:mb-\[90px\] { + margin-bottom: 90px; + } + + .lg\:mt-0 { + margin-top: 0px; + } + + .lg\:mt-\[10px\] { + margin-top: 10px; + } + + .lg\:flex { + display: flex; + } + + .lg\:hidden { + display: none; + } + + .lg\:h-0 { + height: 0px; + } + + .lg\:h-\[642px\] { + height: 642px; + } + + .lg\:h-\[855px\] { + height: 855px; + } + + .lg\:h-\[888px\] { + height: 888px; + } + + .lg\:h-\[890px\] { + height: 890px; + } + + .lg\:h-\[950px\] { + height: 950px; + } + + .lg\:h-\[calc\(100vh-66px\)\] { + height: calc(100vh - 66px); + } + + .lg\:h-auto { + height: auto; + } + + .lg\:h-fit { + height: -moz-fit-content; + height: fit-content; + } + + .lg\:max-h-\[888px\] { + max-height: 888px; + } + + .lg\:w-2\/5 { + width: 40%; + } + + .lg\:w-3\/5 { + width: 60%; + } + + .lg\:w-auto { + width: auto; + } + + .lg\:w-full { + width: 100%; + } + + .lg\:max-w-\[240px\] { + max-width: 240px; + } + + .lg\:max-w-\[448px\] { + max-width: 448px; + } + + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .lg\:flex-row { + flex-direction: row; + } + + .lg\:flex-col { + flex-direction: column; + } + + .lg\:items-start { + align-items: flex-start; + } + + .lg\:items-center { + align-items: center; + } + + .lg\:gap-0 { + gap: 0px; + } + + .lg\:gap-16 { + gap: 4rem; + } + + .lg\:gap-28 { + gap: 7rem; + } + + .lg\:gap-5 { + gap: 1.25rem; + } + + .lg\:gap-\[350px\] { + gap: 350px; + } + + .lg\:bg-transparent { + background-color: transparent; + } + + .lg\:bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + } + + .lg\:px-0 { + padding-left: 0px; + padding-right: 0px; + } + + .lg\:px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; + } + + .lg\:px-7 { + padding-left: 1.75rem; + padding-right: 1.75rem; + } + + .lg\:px-\[20px\] { + padding-left: 20px; + padding-right: 20px; + } + + .lg\:px-\[50px\] { + padding-left: 50px; + padding-right: 50px; + } + + .lg\:py-0 { + padding-top: 0px; + padding-bottom: 0px; + } + + .lg\:py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .lg\:py-4 { + padding-top: 1rem; + padding-bottom: 1rem; + } + + .lg\:py-5 { + padding-top: 1.25rem; + padding-bottom: 1.25rem; + } + + .lg\:py-\[50px\] { + padding-top: 50px; + padding-bottom: 50px; + } + + .lg\:pt-0 { + padding-top: 0px; + } + + .lg\:text-center { + text-align: center; + } + + .lg\:text-\[32px\] { + font-size: 32px; + } + + .lg\:text-\[35px\] { + font-size: 35px; + } + + .lg\:text-\[38px\] { + font-size: 38px; + } + + .lg\:text-\[45px\] { + font-size: 45px; + } + + .lg\:text-\[65px\] { + font-size: 65px; + } + + .lg\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } + + .lg\:leading-\[36px\] { + line-height: 36px; + } + + .lg\:leading-\[45px\] { + line-height: 45px; + } + + .lg\:shadow-\[0_0_3px_rgb\(60_72_88_\/_15\%\)\] { + --tw-shadow: 0 0 3px rgb(60 72 88 / 15%); + --tw-shadow-colored: 0 0 3px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } + + :is([dir="ltr"] .ltr\:lg\:ml-4) { + margin-left: 1rem; + } + + :is([dir="ltr"] .ltr\:lg\:ml-5) { + margin-left: 1.25rem; + } + + :is([dir="ltr"] .lg\:ltr\:text-left) { + text-align: left; + } + + :is([dir="rtl"] .rtl\:lg\:mr-4) { + margin-right: 1rem; + } + + :is([dir="rtl"] .rtl\:lg\:mr-5) { + margin-right: 1.25rem; + } + + :is([dir="rtl"] .lg\:rtl\:text-right) { + text-align: right; + } + + :is(.dark .dark\:lg\:flex) { + display: flex; + } + + :is(.dark .dark\:lg\:hidden) { + display: none; + } + + :is(.dark .dark\:lg\:bg-black) { + --tw-bg-opacity: 1; + background-color: rgb(13 14 18 / var(--tw-bg-opacity)); + } + + :is(.dark .dark\:lg\:bg-gradient-radial) { + background-image: radial-gradient(88.77% 102.03% at 92.64% -13.22%, #17203D 0%, #0C0B13 100%); + } + + :is(.dark .dark\:lg\:bg-none) { + background-image: none; + } +} + +@media (min-width: 1280px) { + .xl\:absolute { + position: absolute; + } + + .xl\:sticky { + position: sticky; + } + + .xl\:mb-0 { + margin-bottom: 0px; + } + + .xl\:mb-8 { + margin-bottom: 2rem; + } + + .xl\:mb-\[25px\] { + margin-bottom: 25px; + } + + .xl\:block { + display: block; + } + + .xl\:flex { + display: flex; + } + + .xl\:hidden { + display: none; + } + + .xl\:h-\[888px\] { + height: 888px; + } + + .xl\:h-\[calc\(100vh-66px\)\] { + height: calc(100vh - 66px); + } + + .xl\:h-full { + height: 100%; + } + + .xl\:max-h-\[888px\] { + max-height: 888px; + } + + .xl\:min-h-\[565px\] { + min-height: 565px; + } + + .xl\:min-w-\[600px\] { + min-width: 600px; + } + + .xl\:max-w-\[600px\] { + max-width: 600px; + } + + .xl\:flex-row { + flex-direction: row; + } + + .xl\:flex-row-reverse { + flex-direction: row-reverse; + } + + .xl\:items-start { + align-items: flex-start; + } + + .xl\:items-center { + align-items: center; + } + + .xl\:justify-start { + justify-content: flex-start; + } + + .xl\:justify-between { + justify-content: space-between; + } + + .xl\:justify-around { + justify-content: space-around; + } + + .xl\:gap-10 { + gap: 2.5rem; + } + + .xl\:gap-8 { + gap: 2rem; + } + + .xl\:text-left { + text-align: left; + } + + .xl\:text-justify { + text-align: justify; + } + + .xl\:text-\[16px\] { + font-size: 16px; + } + + .xl\:bg-secondary-bg-light { + --tw-bg-opacity: 1; + background-color: rgb(243 246 247 / var(--tw-bg-opacity)); + } + + :is([dir="ltr"] .ltr\:xl\:ml-10) { + margin-left: 2.5rem; + } + + :is([dir="ltr"] .ltr\:xl\:ml-8) { + margin-left: 2rem; + } + + :is([dir="rtl"] .rtl\:xl\:mr-10) { + margin-right: 2.5rem; + } + + :is([dir="rtl"] .rtl\:xl\:mr-8) { + margin-right: 2rem; + } + + :is([dir="rtl"] .xl\:rtl\:text-right) { + text-align: right; + } + + :is(.dark .dark\:xl\:bg-secondary-bg-dark) { + --tw-bg-opacity: 1; + background-color: rgb(17 24 47 / var(--tw-bg-opacity)); + } +} diff --git a/apps/smp-server/static/media/testflight.png b/apps/smp-server/static/media/testflight.png new file mode 100644 index 0000000000000000000000000000000000000000..8111a69d548505d1a6d7a587f3c7465368dbddd8 GIT binary patch literal 18151 zcmV(|5|JBz4qQ~pL6acgP`xsZ_O$9oL$z}{$E+&5=(wCM(60!#bXVR z^=7Q*v6#m~9zEvo9Xtv=@;q`p1P?E99ASq2I(Y9nzGHtzj^oYs;hSgac;DzVnRrju z=e`*=GiJ@0r@Jo5TfX8!rY~r!h5c=o&kz}CU zPx<^69>YAIqdq2(d%O@W=cEB(<8R<`%~PhYjH9LCjr?yQw!-`uL$)H0 zWY6+($Y|wnw+;ej%V&MFA%0KG#O7mHF?>mNsc*9232Ni#_MG6?L`8nhFUeCwJROtp zGy4dbpQl5OI!}}9xY@J=*bJ}FiemW|8G*7EpG9;L=6;CoF?z+dwCU@JE?!#4q9 zUOF#NuUJx`mn|;P`Q4Ehb&_xJIN#tYejcglo1WoWsQ8hVuuVjGw=zqQW?YC1@rx#Q z^=ytOTllS|?t_6j%0mu8;Rc#b`@SVSt%o6>HAR`}1_&!aP0K%;&>nb^7$a zGJThs0UEY5Cz`mu>KeYz|Gl2y`_Y6)T+IwHnjs4EP5kiTePHG`ipcdMBhUd|fzt!vuU>xWpd{$%GY-Fd1; zU3tu_$WT?`x&DU#`+b)x6HC53>f#?n@$Tb4cZWZ)ev9*IwN3O+miRy4)S=#g?1>V6 z;ZT`QvYH3TV@4(s{II2YN?f3@J`Xv%vCQF`YDT-@{E(8qf*!p*06IH(Rxe>p0{n5~ zu_`@2Ugz&Q)cd&%9=T|haW0lArDf;w1;1{2k^cC?cAC#O^4EJxYR$CFr~UDN!&PPDO8(wnzT(;X~LojLlMe&8Oa z_#T$?y?x}O4tm3iqPp(4Jv6QGRadSdzn(FX+QV@T@SPL`AjtFaM#Kk#|Ke3$^c=QL z;J}YRSyF#%_kIeYS3YjdxJ^Z7LfFbpeK}gsNUEP101Pq4x^skG5j+PVvu;%D-*4`u zZPS82bEK|R?EBZ|XkF2#Ywwt}B4bY>v^>|(=XyS)ean0^Z(Z9?e|>2O3!YD}y>E(+ zFkpm9kl9+`>k1Uw+C0Gk%XsYI(dUdi`lQt6*Phi)&t~Gk`{5a7?|Xqt&#}}i_p+|- zH;8`q{5);!^%N7pt2jE#Ad`s>7c@ZHp5CKPTX}F^8?R;jMuWioJ zFWo&!_noOL5cc!pMvgNFzhY@o2_sy`SKKp6r})0w8*}mXJZ_H%f}+PCWD(yt4DgSa zcZJ0Nr~PH6cEvdi;o-2eOn3k_5FLnk0lQ1bOOm!R7#am3iNb(Mo0p|NrDB|sD)Db{ z<3dTxIIE0;;vC&wsg6Ji>#D)LlFTVS|NWjw+; zyhVBX>9M8#)W&2$GM z=sGaD-pd5*$KL~-f%w-h%+q_$Yo}?x=$qNbk0F@kX)}|Q9GMh!69KUq#2(T_)U;GE zqcIhF_&(Nj!V4f}Gq;UP2AR1C(7K542;Zg%kI#+9u}wsk%@{NSt^x4|%&oK;Rwh)M zYmV)kcl@TC@9V}R723r@in;7H_Zuf_@K(M=Gk|_v8=E;ZlW@B(V>SFNM&6&>uOobs zS;?4b`%E&y6=4y8{v5t+k!SI(Y`5T9+nQM^5=SzAnE$I`m!P_ON!Kj^d0wT zo@?qxbx>&clvENw#L{wPhRup`74IMMX=7hrX$e1ZA3}|Q*wRRfF)azs7v{H7$XlB4 z>5z1(rU59DKhR3PZ(XgF?s<#NZ-VO9Z$QjhKK|eDF43wkPigJhT7qf9W6_oTUpp{B zr!fJk^MOFGTwYM;`TF5Xi>hB{Ylwu(hg8eXQ9 zjW1201i>HJMkXjCXO}XrXyX|lt!e@kOi&9EG`xRg+^1e9%37W{sBA68(vobO@D%%7 z@H$qqZ2jJoIwX#No{4AFc+BVX!FKyTwKQk$xsHE^|oT>l^+xX!VPmf)6L1y{bxd=3qLeAcHQ zvOrzOZu6ox%d?4pA2P5JZr}z8N>Z~x3f6Xew4TSuca)Si2sOZ5{AC#j=y35~!XWQb zb}Mf`UR4u;3L#tTLUQyZi-^dMcnrJ-FzS3hz}l{yB0lW-{wbf`#tI#s@zunlpyDcK zxc6@CP;Q`SYfy8n8U7qN;JJ$m^iO-sF^wsNR3^kT!6x-LA;XZXZ5895frvAM^|v5y zG0E50%PvA2+aj3Qp}Iw6FvZ=cefs@1Ipsh57C=%i9e=52E=3h| z@Qa)=S2g2riI__$Qa~_^Sth^)e4$8SfoY#=%mDF+M@Q@G^=8I!ckoi$U(6c7KsSA7!+a##GdxmRN$Y~-=OTvXrq%=dHTAIwCP?LK;L->_! ztC9Tp>cKMYo%EGA0UHl9)s~n(+zUV#jxt!(Cj@avRajUv89sh=OwvGmo>nk6Il#v( z=G-+F(2g}eS`h33xRmA|B?+fl5E(Iqz(RmA0YCxX4o~~)c!HXgBxF1wtVc~L*(LoA znuXLaDiUQk`I6aZKAM5gNmS}vhj_yLfSX5O4~nG zr_byu(Q#&gVp!0T9ec&^ks3y5`N*sc-9nUL$pY-Zmg*Di89)m|ZSEegt7A8c{ArV> zy(KUK?ys)2XM)fHGbc2Hu!svX!Ef*357#ggz>G=jH*<+AV>%|PJ{{y^tYcv*8nfk~ z-a?4Q{H(wNB;goOGJJE7L~Fi((vx7D{1OI|Jq+)+G2kmC4rBZBNr0<85e-J4&w!?z zfBgBqCHnV$Wg02zqSJzC4KV{Jj4;Rp@FK%d0DkjK{emDJ4;Y$d&Bvy%?zeVaVR<#s zCca6R7d;ENSh0e*r(l36MH$Q~CLA=(x^6EsuF=Xh!Q38UEwF^m3hEA}@ANWyahbhI zQ~{L>sanAQ0hrY4N$=Wv@4L{R$szEhJa-UCRM11GYhesunx&Dcc!tFYxL&w&z#*Fl z@^s753VoLS+f%bT=baahYso@LiUB;O5^Wb7uR-)~H9`8i4o)*D>hZEsmJ{Sk9aHCv zhCN3u0;CQBry#l}!k9v!1*BmFX=w|ovXtY>ED6N~bze3Xe@V`H03)8@xthlp0zpZK zh1CRf;kvsbO2}|xp#FOJ$0KY4eA^$EQBWo>!tq!w0v?+f>2F{a^w61_BA5(9xb~sU z#M|5shC@sah#c}9A0sa;GWolEiEnWzF7 zwh)hFYhr+Fu`&f0EfY3hWUYEPkvN2RU%)nd=V(oFukaI$2efQ#Qm|NFD!f3drf=gs zkG{OWOj}M?ReU?^T$D&LxZ(~fIKs>WHw(mt^gylFOnAA(2G>_5)|v@WHwZwNJBz}O zWTX~EgeE+~pcQk{UyM|7ga<@CnPf2#c>Xw>q&2JsV5aI>_$$K-2Icw+kGL@#JXxzp zfQ(0!a07@32$_h0QAXm&`1;RAc{pPSn2#`eY-y_iU->}{-`M+g3`qvrU;o0MG7U|^ zez6u=P@uIu0kwKI49S{|K(j!yI}O-d)5&U;$Kp1fnNx+9Rc)`O$=!#gWOD^y$$Xaq zc(BKZnF##Wj=Nx2+ zOsn;HgsIU4;DqO~3k7LAIH^^Zv=)I#N4^7Qq0BS0o`Dz8{ulR_X^K_X3Wg`wvKE1F z4RS8Xx;OyL(63#>39zng-i+93Ez_JEw7n2(C8)7UfwWagj(FA7-Y8 zMi}hy!sEFJA6rFa+(R%b=C;~`cM=@CYSc|GI|c0l1LRyL#ItJYf`f_%_Fv@=_(+@j zbMyrJ&|!qnvhmXPeTDuJE^-au81Tkd*}X#)Z#}=iY<`~h@^Nsix|~~E4jXCsDnX(b zTF$a~!}kwj?-M32U#e-1hwtHUYJnz?e6u<;Cr*%#G>8a^2_{&@c6~8xy5U(F^|P_; zyGSf4gCLz?Apipa7eKS-n=A@auC5;d)68lV;UyVBCe)dj*yi`Kj=J$cMcHPc-nHyGJTY3Q$46>PLURu4O3DDHHq)8p5&|^90WdQ# zB{(M5;K3$C<@*MIbgKG7p<)kp84p$om&fTRi$E=5u`!bs=>*{pPS#Zb3FglCWl9T> zg#m;WOMoVXfQ7k|Mi|7puFRrc_B8l6&$xexao&?p{e(Et-tN)xmWL+hy}*tcF43E1|R- zzBixUo50PEf<=Z7Vf}%|W0Mu_LqkJq*HF3}Y9t#lSd2-D>M59jg0un@VFoM+D8x!| zY%LJi3K#U{=rG&;a0OQu`(aW{GG#sg=C+}VviS&DAksHHQ|IH<6>#w!vn?H}maKqE z@H(t1@O2~+%!F`o;AcbSj#Z+%J>Pr}g`;2)Rj483)u$pOAS3M{kCcvbRB64Ep>*$J zE%%ItdB#pcF`Xw89)jWWK;Yn+A;!M}bIqot>dUaGQ@TCDnAa8-t610VNu-LiKu&_w zWfBOQwDAfy{=U>4ipjS@l$7cC1Jv+&d=n3|kAGs?R}+6FPXuZL_KekI;oVf}SJt5P zcQl}rDsl+n**)RYfk`d>c&^mrDjN|v*ygc{Yzq49dlCUo^3&H4XunwS%oK@$`F63H zgZ2!1BzUf5QQ$EMUC|8x9$rCEzbGB^U~4RqD8-9ihrKpHX4O>52%N+MfW|%}SOL}V zS<)}98a{xUNJy&=L`kJNS9a6=@t%K`m;**V%Oxy!|l?X_kg+g1;#B6rCthWi~x!Y*9a|8si$iUl7YG~(kjz1 z!J}rXzkx4|0|UjMoH5exCnVk{tB_NxesKEsGc}rK&R@j-@Rf@SbOx?XNrolU!Uifb zJ&Dqfzt+j6NJMRurWUl;H6}o!2I_Ny?Ey3v2=0XimHG#8M-fBM3IvIS>X-f;>LD|Z z3Fh2!rmo@xc~cP+BYYxCsN6zm0oJ^#c*T#1E*qMJbo?am%*#8=kVPuTKG3cUAs|^f zVVMSzR)=+_R)3qPQp*Pz0VCcIZSe5PD*LuF7JHX5AeWtq6Ru>KkTJ-na^t4(^SoZc z{xqpV4~43$aaRrB1rQxA$(R(mQ}WUR%#^rVnNdOD&ZejkG&ALqDX8`w9byoi_pFK{ zkuEDkykv&2|9sX0+jtI(xp>u7v!=B&tQ}}n=mGS1)`Q&u>rEQ!13uitz(xE9#wiy8q^S4 z+RBTwOFF%oGJeLF?=a<8QxzHWgC)u6wXfd;?asr(Ojh-XYl9Ki@`XbapxD&2%D&RZ zHep3tT*?e%RY3{5}XVgO+VV*BRjZ9wpM<gyH&#;sukOn*9uTFD%@EpUgc*ek2UAe&Wc~5kEWw`0Zzc!^Q#is{{#yACH>Y$z<%Cl0GJ-h;C~G93KIn6Ek(X zxZk5eHd`o4EeM?i3KmcT&_en#u`n4PWk`OavPLGa)o&vGSu1;_PTCwWrD&abVTFce z3KIn04Fn2ipjK~2Kk3Znc-elg%mgjC2$V7dPzl)x{OO5BBo320R?^k42>R@0e?L3p z)@+f4T{@H)sR@=UH2vtQ8mmh|XZRXU&1xjwW|o!*B~(4et(ppI^cxc2N^K^~t^B#% zOMKuu;1Mlja9yvH6AaqR&`+BJ7I-wW1t8xN14znn1B2|EiPDv8R*2EX1SZao%A%nu z?c<{`1FB93jhYbOZ{_caU_+7#lnW^7-eXnTzzndgQ`>pO{*eSixm3;sSb}Stu1M?J zlE;l7n_N{xnb+RJB2S*!{VW^+qXo}Rc+b)TFri@P6)*$!7BqOP8XyZns?aK59Ek7R zKE6v67(W*mHo{0B5(dDs<#;U&ddyw&ZLlZZ9!mlU0_XuL<7QWO3ss#14S^k2bi_o!#FV$MT?D8JT-csJ=a2MAYb7nxXHeZFGh`kpiq@WXb}g@0!$Uw54z$x z8RzsGBE@9Z%>;K4y_>B?2w27zYeuRDoC#(bne}zTh{4U0F3+lQo6E`l=9#B>z0Zp&#&Lgg?tQ-wK0BymRyU{X3J+;Ek&%e9uyz!5YO zw7?=&Hy{(uq(v+`h}a3{cX5}lJxuLyLK(p+ifI6udEp}Q_ngow6B3h({Q2?7A3lSP z1&88ddsO&UX97j0bj#L3A;}Cg5N8OS8(zRe!&SO^&{Nkj(577{Fak^(Z0`aFhyaXQ zl}d-5&oVcZZh4-XQ|T_1P|_? z2(L*Pmrx5D?g<+9H`y{RE(Iza&In>SdBQ}6ZmwsA{B)x!`rpqi* zKU)t+M+b){d{ZxA;s7xX5HWeqxVp=_3=U}4RKj}gHA=0zzys_I5)i3XJ&ATLpo8+Y z4WeGyp@SQtkNs4MpXmP+2W7qqSoxX}_+k+AK0IEd;prM(v)EJDfS^xjUI&Ko_x^TL zd5zTi0$+wqWfy)p?SH2El1+$`$Pe(O4NuoqNqpY!HZIc9L6r7F%P!~;=@nc}z-PX- z+9Io7-K-o|6C$EmwQU0J`nH*hUL^y&yKk)S&_c~tyh!It9Ggp}lnoGUbe(YTpam|O z-(gCzgo>vJSwO@mP9d>nEh=fe6h}CUK4fA-gxf-7Sq>{gH(rt^KqVANwbv5HY6uMu zfa{15yWc3B?^s9ZzDLJ=2IsH)2?21#06geizkCk%8L6ggem2MlBSoz}w zXMR$3v*zIoyX$b7E*kLE?j#6g1s(}1hkJ`er>5&B%jZ)6(yf`2^X6wsp3KNAxP?H) zOUk-`K&lQQktT*v0&{gcY6Pgk0@LPk0Lvc^a`nEowl5h73f|+pm6~2GiA)ukVXAEV z*Ro|bmF*?C(#6spmh^pvlpj1+roMutb$uG3`i7Z1^V%IeJzbB-0J4^ZS!GtzcVxa= zxj$--4Mt~u%>aHpEo=vagbB|hF4M<1>H7}G^xfCu%_V=WYv-(HO&-J5$q)~Ubf)aneMd_4f@Qjz2221Puwfpe zn?9XDEEvRYo35?e9dNY4oml{aa9v# zK&2Z4SmomQO;^DrhU$(<$*ic6ZVB>}`FBVB--Qcst;$YHx^dSGJ%72N3;I18p3#i@ zmQ4k^cc?-mGqtn^XcYX3;RSaxs&TbYwils-Jf=Oa=LiDD+(M!6qq895^V7uR$Y<_a zk&#RY;{PEi3;?g6MzwJJR6d@O^`xC(X;dI1Gf<1&Gbl4;EK)(Fpg0wODdu$$_2%8v z^!+EN=^MY)MW5SIXLI1urU9b=anm?^EPh5Olnl6T;J6G+=@U@hPX^0mgDRm!1(DP^ zDp(gFl2!KBXPyx_6qIC!gB^39j|HbMRpW2t4KK}FoICX{a?5sPZuvL(3$}-L{0@w-hO2t{rkfc)YI5yr6{bOD zz*;0ljPN$WJ)#T`i5$Ce>ObV;lbw;W3<4ffSf*OW))C4?Ge{mhANdd3pvDe+@qmuqxV?xfF7ZBg$D-s zw-+;rymEOPz3mD?$0xORxaUxb9y(g4;YrpasFu!)xMw0{Mw&gr1R^e_D*bNll@PX` zmDYk-5v@@`By()GNm*Dz5#^o93Ev%XY->-VH^ERF!93(>HjZE7<~2f=OW*Ag$4 z6fc`s3<;0mO|7n1{HUISZFxob%}Wckl0h9*IwZM|H3EqL;82Nv{YyhsWo9|ezuSGP z#8T5nCzyB(x;*M@6T0K5se90+*IK$f;nB!!mCcQ()biqPZ7yDZekYw|({TH~5`FK9 z8QRGj!ec76<-HIXg)6X?YbX;qf)XyH*_jpl3z*oxFPk-{)&V1!Eg>^{0#Q`i6`VTX zv=qxizu=~1+Bh~_-K;zW3uQGO>1>M#2wHI~L>o#@LZF@`fACFGcW5-M8_Youv z+o2^}j?1K$(Dyd4T|~Q>P{uYjL9uRLM_fI_V_6kP_Efj-1xOM5QwqW)u3xRYPv(h} z58s{Y)fmE8f7Uj>NDCqHrfF61)oP8@+5{{&>qTq1#Zdd~&H?bT!~+-(um*tY-U{OX z%V7p_H71x&n5gApJYl!(9Hnd4_R>h9K)X)W)Y35|Rp1c}cIko)fPZL|qNyV8Z4=6y z=xGx=6UVcT#0^r*&t2U{*R5-(@9dhT&u*QhqvKVjg*+iL0!wZ{Sm)y|?9|>&EDn(H zCIj@}u1tUqzSUd}EiwW@u3e1XsalG!|4ZG5#SJm@*Uaa)TnwOkwY5r7tNKMf5#jCo zf!arQ5szivIl5qBo-Sv#zpOXMQWL0sZMPph0^%Q{(^EAi`O>npFxOLHf?Z>@d3h&Y z!(;nNNyAfhRUS4pE|jVVff1Hn$P7`cdP*t*C@ZFRzr7e;8$1PQo|m8Bu9)E;ADE(V z@0e0HyR9G+rCF9%NMHkejAGA;QnZsNS@Ic~8!b9!r&xEqqYZ>8hWT{^{FURUT zrw9+p2XSBmwlMME@cDyGlqySp&f1VdAA^ED0Qi#!4oAFt8PJ4Wfsm0k3_Rb8~>l+UhZoknMT zrS_Mzh8dpn>Fxs+g&DvI&^~QJ8=6iH5LJZ^md`N*9iOPtvsV`B;=%cB5~k?uk4>2Z z+l*X;0e$_f(>t)&VI|N2GTwdMvZOG9(1-x79eHZpGsa@Ld~6wzXrci!BB3PRRxEG@ z({0a#l&)6y{apoGJ-?{Xb(KY@T$L#ZQZ;M*7qXON(yv@zP|y85;ZS`5XOOxg-#qO8 z{fEo+hR+?K5k-7U?I!`4tpH6IFNCn~-Zw?7`pb01>K+9U86CF4jPoFb2tcd1e@37mYvQod86hnABZIKET@GSu0r^+%rK>vNq@{dIlTB zCm1GXH6Ifzm>>xGoJen>V^wIu+d(QDxIy1UMlnhvaG5twK`wg{Ms^&zQ7$WhY93L% ztC+F(swR*BxOPE1E$J($_XxQrabq=DjKWl2!4q=TiURxM1zOI43=#%2;hSOz%n>B! zzC&etqauD;g?{68h>nb%jJsb-8}Px7jMZrPG1dmFI_c_V-L&(xuQb8XxKD$u+An0u zKg{L<#GNif(tV{BzzAztLk(5xx=n4nr-+D3-DMpa-;FDEfUT2nV=NsOzsGWKh zkn&S2n3Y6Jjjk85iicl+*-|a_1I9fI_wRwd%n60w?>$(iU;W&E8lA4FL{AEEQPTZ# zC2I8Rf?=+^_8M)lhCKz=R(ns^=mZmcilrP<3Re(8p%RNNqJ_uU_MgYhv7TMhC2TT$ zwbp>KLA&0PZfML`O!0fq?xCwgZBSJVt;704MJsXatwWWtDP-1Gy^vmLoc-93!4u8* za&&kBExUoOvO(a-ODjW&&uewuJgf1+b`_H^SL1fR?rU&{v%!_aL|(#vJ;u@=4f1kc z+=2%&`5!znOKQd1P9*>GdRSSXfZn10FzoUO3|T(PhH^W2F|DGVf92K1B`&@H2Ds)SCLw4DW^! zte{7bPXk>n8H?H!V5`=oMR+nz5t#6uf+a3T>v&S3F7X;t-<}s?FTLOrb0*jP>AT12 zPj1}L+NH+C&ug@AXI#VJn`0uC*olU!u3%91U3;b&td{7Sb$v9^*T$&6MyJ>W^fBnf z^`2z+aTt3Tu?j!H5;;6kr;US}i9sM0oNCuV5x^Hr)5luyeT)sRSfRDS!`Kpjb5VH_ z&Gu+UV~C}_Y>F6%lnn56u3(Z4vw_LUpeD)P@Kl4@anpeDSS zP{(=~39EugWT0GOuZuvq)uW?4**9)GO;@byre`eep?xE$-9vvvpRc{Z_aN!eNR3T{ z#s=Gl7$}a^Y4frI&qGcjb%dSkp01dlLIMidfSKe|uNt6#-!`q>+Pe>wb-&_NnmD?4 z+$!+UcX!U322)l<&Q;Wt(0pl!p;0-rXi#MCEG3g+efxYy@9P(~>DoZ_?62$A?6?p3 z>>xH=y9=1`%b5v4_|BrX>w%Q}W~0a8{g-&6{_I=(Y5&nv)Wg1y5RiNgB%@_CGKTb} ziC<)|*|U;2wl5n@s?ew&$GMk1g{7xUbk(|fG}%$4-3$PaO_Y_&-^ecNWZ75cZ~%Z< z>vuncw3V#Qz$A#-&o}qjYt~Id8^G0k$2C0)fIfb_syvPMf=IKbp%ZEV;Q~J~TvN|Z zm(NDki4wIIFn~M@GZ1|rlD~k7zfKWfFT@DS#h|V*A?NdiY+zfzlx-iMIqESBD}-ExzSn5RFuk&mI=$oigemk*iAF6JpwV| z!{b%DY1=SuUdf)q%6YVR1X!S|(ECybfH?F_MZ2f%Y@laYBLJ+eXE2-h^a9&nG^d=^ z+tk75pJfdLp?Jr0dg(`dN_6l3vbx4XF0RJlDxSwebC9*m*o?1$pgRF1Grc)C10*C{ zp4GYGY}WIh>5>)pr9&S0rC*&S<$c6 zyq0)F_<{)Cx{&amM&EdBJ5tev-!eqg(=)2bvpbaf2)9I4Grqw9Iua!f$P)qB6Ul@| z3-^|23L%3Qq_VEg*Usk}nX1uE_aC5*D@JJZ`W1Al(oXx>y_+o6mCZ-EcA~6t0g_Nq z`B2@LFE8jGMJ&M!yFDcUYJY<&@*QeTZ(_dXy$-W?7PQ%GPIg&$F4p?rCE{fi%xTh(w`Aiz_w*!pGLd`{Cy~FGo3M z^2dj!=)lP*=o#x5(N)VA((&m66I<_`hJHyv>*x@U-<)KR;m*AkI&TrX#_amRjRX@6 zbm^=kx}2VvkhGZH$X9IYpbOcf`0}i3n?(wv>7oB1{5BWlh4B4oys8vVFN9V;n3j!-mr6@7q==s+`5$J5SNT;c>cn z%>uf7Ngs`t^GdaQs=x#wSQml73wUIRO~klQn^_wmV1!b$nTnnx%p*Jpl)GKX;PQ=^ zchVOgm{BI;Gt4~gd65yjp{A~U3Ij-2X?QG>m&Gg^;4NlJ-?XHtRNfjSN?78d_U9{A z?@LPT2U6jqg#hCqxwkVBUOL*o++02g0)A+*hDQy+t~20g7LA0SHXRZM({BKI?Sx(t^qg zPL=hY&lDtmdHW1Af}g#TsaZBK2?FCCzGXW5WM@y~aS`S2tzm$7IfIh(2Q|8f6c@~@ z8lj3NA{!6Fe|y^qb>^z{z`jXlAWzwNsSveCvBhnUz@to-Pg^!zx=JPmWivsHRqWDs z>$Qj%VA$3p+`!3NrzeG_g@oPIO@FRBtzn&_&{@KRr>pEO)Yv?FG>={6C9L9?_ZFy~ z)h1&6yH3=2esk(~eVu}aPt@to-DNHPj*2W-`b%Wsw!Ho0SgnSPb9YdpWb=ryO2ETA@ z=F(+F-QTXLTb*jc&(b#^8mC9t2CD911y4oAR9dt{(%Y}?rN8~rgqj?1xwJr6Y|>-> zcY+&?OTS1Z`$R$sh?vAm69tSGwusagQ8DSy#=zF4S-%yr-kw%~OTYJ}h^?*kOdm!Z z4AOsOq{bSe#zMpXzo#2*<#JxQ8>mbeyBN^RDDAgzA#Z6gSB<7~t(iC}{+yAyeK?u^myom5IVl@U1p6@vinOC75l>+_XqaNUX5{RgV7;&Sw^7xbxTM9Kh?Ae};>;!_W)o;~xNGt%ps(66ThGG+*~DVj(OiVYFH z7Qy$^dv_V9PW=n7Jsg54h8)rZj9YEg7 zQ0YZsc@ehxv0D1DbYiAz>CRUarfZ898wp3ZE-B-?QUaNN{wLG48s$)ZdD9pw0zYvO z6ip;b#I~>|m?{hU{p7M*hP zoXnw_&uZx`qVECs0N<$SpA@$JN%Un-C*fpPBhLnnG*wquL%V9tW|Y{LdWr$6$(^Kv z22UVfM?qvZL=qOz2)GqVidhD+pSgRAo_}7ODxmPCNH9Slt#wdn7X?Iwj!=#=$k?zH!As^EhWM zkbEIh(L^nw!da0el3Ua_*sl3f{YvtK-^k=~Y~x}1yRp6ua!!@ms@|f_Vu0lLw280c zN@r4g%9$S_R*kxQS8rdL6)H9=4WY{)?JBbiSfLkR)S)6-GQ&m+TQa4t_(jU}mK|la zP~_d$^|47Q@Mjcilb?klGi*IvIaK|EnQ`A>06~-@d?YmSNMyVyRi@my&}=l=))xOE z)sFKc!x*m9jB9{07+}J+ClDCm%#^Q6vMmtrZj&nGwv0?8DKNp8x6IJtkvhF-W4kJg zl0j%tM7v&u#23*(D;BssTMkrc_i&9~e`S{%NGKtB5;B54sj|VdJt*t%X(F{cg6ly7 z3k5G=gi@6p3}XABA|7CnafS%ZLQ4HN--ie!LpUCRrRnUAFlX?+5m)ROz~Sgvjg}%S zRS;1$sbQt59huTMfsEE??wqF4X-U^zz)Vnyq8`$@J6_zA26#fu`-iMe@cOOS_Rvc= zbtv_!vS`vm(Lz`@Ht`($j1qs3$L%wROuzRHs6$2$H4yr`q{2`NuSkcslOmokNR*;y zCv;jY$d=lBLlzcQ$+H!i(G;2{paD*}a{VCqUY5LtJ-SY>?rP)i;;IB8)v{%tlu!R~ zk|t&)UC)}JQVlp-I0825$x_;Y#}mhEbno7Z@-BY+IX(1?mvpG^A6USNsF@CUhy*Zl zEt5*B&_qX%@H-;@T_dT$G?O^{`Ey&aUQ?+RAMOuTOK~2$NS3_pp+>WDF^}L!7RIN4Ygz1=2iy?%`g#F#^ zQ_PV*z3kEs)uL6(81xC9uFx~EI%MHJ>ILze$A+p3TVR37Yp>{{H(u3Em#io%n+xy? zX7GIxgIBHpi^IEsfH+>T(G}|Ym-Xjqd4EB9F2E~TnCZJ#d0Rvjjky9J5>N zVs62dsKI#bNG069-qFYS7;*vo;u#cuCe);X0q}U{>LUGcXDMC~DH>PMBp+ZT6g2+B zkEiHOS9j5?7~y~K(OKncssa%biIA>v?6M-lRlK+tV1qrwMDx2nx^#Jwud_|1S`j}# zI;tyWRUw58GLlx0T}M(QiDyMnsZ`LWtjnag;m$#=V(VGoP+cTLu&L~uV>e`x&_G$; z;5k?g8fGgSXj#K>^49lQkM*0C)XG?exhX zOt@o;Br;r-bO$WeH@GP1i$9rGw&_)uchZlTv4+R%s&PVk`*rDDeh^$n!Ds}4Rg_a9 zDTP%wD+cm(-e5s*0?9-gXGTEwY>K^_*{ZKJM9q&HUI)y9vU3~*OV)5bAT}zN@E!Cd z0MSRLedEtZNuA`z64~MqxAm!YG}48oGRhPi)o1YaUAM7AG5-$Mmc@K#r?MgBLnKbV=ADm$#b<-<0chFzmJVAY(9%ZkeYF=0yF@ZwH&F&1I~UwL3U)($7}IEE6#pk4gEE>sEhzGYvT zwzC>}+Y5SVp4rdVjY1u$xk=Otm;ea%lgwCmGVx!^sQ&!L1@&3jXD~6b_U6g1)x2@- zp<|D#-~#I16kg=4gjDd)3o3QY@I)fgmy93CCb5UaXMtw`Zw9Xe5ZqLYrslVC00?;M z9oO~HNe0`uv6k%!iZasUVIEUv`*^zDSxG9i8vMU(|5ps;=#32Crb>RpcyyW>GA7Rq zOf~@4t=miViSJFQegW_j`q;gjty=2Q0j|&H5yM-~j!P{HT#KIvm#?d#^@`}Q^^Isy zdzoZ1*?hqVFwTov^J4xQdLF5)+|xg8q3wsMy6cYzsfUH=>JOh#MLKymW!OD`Pak;NqjN?boi=S?!M9ID`pz1&c-=I#8j%{LVN%e6T{#-q1$Z z@aQn+BDFDMW-dB1V;>N9Cd-a&L|M+nVyNm}&~u9_nG6l_@(DgnrSCA-w0d{aMteaj4e0m247 z`Sjc4%F`0r^ZkAU{Fwos`QG57M)(U*3m3c22cq1?K<(-c?ew-6cGKfWD)i`~s=DFE zyB-j=?jG0`e%dDoVA_4W#*CoXz@5LeKo=}6DCUETo2~e!yvjD|B3-d5t&prtl#koZ zFG9kLhP@I46A}ctprHU9;l*HePo*kb!0e(4Q)DeziH?nhBv-F*qp!YuA?-g|r#Ihl zN?lvd%LFSs`Lp+FH<0If@a>1~b8bZN$Y6rhqg>Tdx10W85v^ym{>D$8qVH~-QLdqQ zx@=okJ36y}4xX2>Cb(opfmSanD3F8Rzr*bBLJ}(Vh%nI>LNaB)eV3h2+Hya@4N*^n zpo|+*o;OXTKy-lPvbjFbYwe!qa{sQ+alcouZ>Mj(ccH>hFa5+Y?SZCl6E39}JbsnO zmqNEW(g5mlipK&TSG%qa9tBSNgU3qr3!6IV?br9H2;2jED^WS~IeOsGaOI$hfOela zR;5Q6ARbe}DM3s7^R#k7o)%$6QhQW;S2KwljBsyhdn-WKdq0}9U~wiq6~rz&+dM^h zn4+<1Uxl8b>gzHi#C(nbAI#t@pV>)Yea}Lbc7FBWoTME`s_NbwxR>|v_@A+uwUl2M}NSeZ`aW({n`zul|bP02F4f2|0*7j#0pLn z1Ee1>Qhs8F;=)Y_5xYNq%{+SNFZ8Ie?w@{rjJ~yXM(@>A^s-!#bM^?LYjxx6sm&Wq zC>Y*FTOm^2x3sWwRPcON&gFgC(e!7q^ zYCEf;f4FsmZh35$PK@cS3HsyY&2q8K4Y(R?ozv8YHqa9G4)B4!Pd7LdA%TNuFHygl zc~03NZhsbFKfoe2qp^TL7WCxk`5W8mciH4#b6%Sgwhw)El)iTVjAH0wK9TE5U)MKz zyvB9yngOyOpX2eH%`Q4nORvACi{AB;URpPpSD)ST=q%m7eU`n2in?fQW>7UZ72^XT zQfqqut3RUheQPdEBPf_rtggCvWr1GESPKHSpvPl@s?!a(PSU^JHASZtlSfcfhfde%$mzOD|DKtu z)6}fCsX=G(h>%{GCjd7jZx;3CXvO?Ity-8@ z1jhra|IQsHmiB2DrjjDO0;0K=E_pkj^u_$@cxD_yF+k%7Dhoc#kyJ~>}g2$gLTrfxQ5}@v2?_LGS+Pn~yVSt-+W+}OtL*uTWH=Q47x4PNu z2%bm)I3elqG?Yd(?O^gjykYKO`Wrlc8=-=x?)BhX4$tMl01|)4W9iwb*%%;81Yd}n zsr|E6<*oj%mG5QwdoFX7eLUOU47?F^FI6e%r{-mpd3>11$6EbCes0dU;;aXFe3Zwp zyFpM)0e{1u5jeh4v zWiIv((aF4PEJixS&%eRXFXZQ2(%C){U)5-LdWsz{xecsz3_C6>tj|UKTAi6j>$|R6 zLz%c=``VM|Cz&W8cU@oN_l;I{%0|bKadnwgk?D4Dk1Bko>9I9_Eh6JQ(QD6@?1)WN z3I&JHKj!~l!sA*V|H|*r#8YHR=a$k*ji$Q4LGM#Z897_m?~*Ovvhq IO () +serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams} = do + forM_ webHttpPort $ \port -> flip forkFinally (\e -> logError $ "HTTP server crashed: " <> tshow e) $ do + logInfo $ "Serving static site on port " <> tshow port + W.runSettings (mkSettings port) (S.staticApp $ S.defaultFileServerSettings webStaticPath) + forM_ webHttpsParams $ \WebHttpsParams {port, cert, key} -> flip forkFinally (\e -> logError $ "HTTPS server crashed: " <> tshow e) $ do + logInfo $ "Serving static site on port " <> tshow port <> " (TLS)" + W.runTLS (W.tlsSettings cert key) (mkSettings port) (S.staticApp $ S.defaultFileServerSettings webStaticPath) + where + mkSettings port = setPort port defaultSettings + +generateSite :: ServerInformation -> Maybe TransportHost -> FilePath -> IO () +generateSite si onionHost sitePath = do + createDirectoryIfMissing True sitePath + B.writeFile (sitePath "index.html") $ serverInformation si onionHost + createDirectoryIfMissing True $ sitePath "media" + forM_ E.mediaContent $ \(path, bs) -> B.writeFile (sitePath "media" path) bs + createDirectoryIfMissing True $ sitePath "contact" + B.writeFile (sitePath "contact" "index.html") E.linkHtml + createDirectoryIfMissing True $ sitePath "invitation" + B.writeFile (sitePath "invitation" "index.html") E.linkHtml + logInfo $ "Generated static site contents at " <> tshow sitePath + +serverInformation :: ServerInformation -> Maybe TransportHost -> ByteString +serverInformation ServerInformation {config, information} onionHost = render E.indexHtml substs + where + substs = substConfig <> maybe [] substInfo information <> [("onionHost", strEncode <$> onionHost)] + substConfig = + [ ( "persistence", + Just $ case persistence config of + SPMMemoryOnly -> "In-memory only" + SPMQueues -> "Queues" + SPMMessages -> "Queues and messages" + ), + ("messageExpiration", Just $ maybe "Never" (fromString . timedTTLText) $ messageExpiration config), + ("statsEnabled", Just . yesNo $ statsEnabled config), + ("newQueuesAllowed", Just . yesNo $ newQueuesAllowed config), + ("basicAuthEnabled", Just . yesNo $ basicAuthEnabled config) + ] + yesNo True = "Yes" + yesNo False = "No" + substInfo spi = + concat + [ basic, + maybe [("usageConditions", Nothing), ("usageAmendments", Nothing)] conds (usageConditions spi), + maybe [("operator", Nothing)] operatorE (operator spi), + maybe [("admin", Nothing)] admin (adminContacts spi), + maybe [("complaints", Nothing)] complaints (complaintsContacts spi), + maybe [("hosting", Nothing)] hostingE (hosting spi), + server + ] + where + basic = + [ ("sourceCode", Just . encodeUtf8 $ sourceCode spi), + ("website", encodeUtf8 <$> website spi) + ] + conds ServerConditions {conditions, amendments} = + [ ("usageConditions", Just $ encodeUtf8 conditions), + ("usageAmendments", encodeUtf8 <$> amendments) + ] + operatorE Entity {name, country} = + [ ("operator", Just ""), + ("operatorEntity", Just $ encodeUtf8 name), + ("operatorCountry", encodeUtf8 <$> country) + ] + admin ServerContactAddress {simplex, email, pgp} = + [ ("admin", Just ""), + ("adminSimplex", strEncode <$> simplex), + ("adminEmail", encodeUtf8 <$> email), + ("adminPGP", encodeUtf8 . pkURI <$> pgp), + ("adminPGPFingerprint", encodeUtf8 . pkFingerprint <$> pgp) + ] + complaints ServerContactAddress {simplex, email, pgp} = + [ ("complaints", Just ""), + ("complaintsSimplex", strEncode <$> simplex), + ("complaintsEmail", encodeUtf8 <$> email), + ("complaintsPGP", encodeUtf8 . pkURI <$> pgp), + ("complaintsPGPFingerprint", encodeUtf8 . pkFingerprint <$> pgp) + ] + hostingE Entity {name, country} = + [ ("hosting", Just ""), + ("hostingEntity", Just $ encodeUtf8 name), + ("hostingCountry", encodeUtf8 <$> country) + ] + server = + [ ("serverCountry", fmap encodeUtf8 $ serverCountry =<< information) + ] + +-- Copy-pasted from simplex-chat Simplex.Chat.Types.Preferences +{-# INLINE timedTTLText #-} +timedTTLText :: (Integral i, Show i) => i -> String +timedTTLText 0 = "0 sec" +timedTTLText ttl = do + let (m', s) = ttl `quotRem` 60 + (h', m) = m' `quotRem` 60 + (d', h) = h' `quotRem` 24 + (mm, d) = d' `quotRem` 30 + unwords $ + [mms mm | mm /= 0] + <> [ds d | d /= 0] + <> [hs h | h /= 0] + <> [ms m | m /= 0] + <> [ss s | s /= 0] + where + ss s = show s <> " sec" + ms m = show m <> " min" + hs 1 = "1 hour" + hs h = show h <> " hours" + ds 1 = "1 day" + ds 7 = "1 week" + ds 14 = "2 weeks" + ds d = show d <> " days" + mms 1 = "1 month" + mms mm = show mm <> " months" + +-- | Rewrite source with provided substitutions +render :: ByteString -> [(ByteString, Maybe ByteString)] -> ByteString +render src = \case + [] -> src + (label, content') : rest -> render (section_ label content' src) rest + +-- | Rewrite section content inside @...@ markers. +-- Markers are always removed when found. Closing marker is mandatory. +-- If content is absent, whole section is removed. +-- Section content is delegated to `item_`. If no sections found, the whole source is delegated. +section_ :: ByteString -> Maybe ByteString -> ByteString -> ByteString +section_ label content' src = + case B.breakSubstring startMarker src of + (_, "") -> item_ label (fromMaybe "" content') src -- no section, just replace items + (before, afterStart') -> + -- found section start, search for end too + case B.breakSubstring endMarker $ B.drop (B.length startMarker) afterStart' of + (_, "") -> error $ "missing section end: " <> show endMarker + (inside, next') -> + let next = B.drop (B.length endMarker) next' + in case content' of + Nothing -> before <> next -- collapse section + Just content -> before <> item_ label content inside <> section_ label content' next + where + startMarker = " label <> ">" + endMarker = " label <> ">" + +-- | Replace all occurences of @${label}@ with provided content. +item_ :: ByteString -> ByteString -> ByteString -> ByteString +item_ label content' src = + case B.breakSubstring marker src of + (done, "") -> done + (before, after') -> before <> content' <> item_ label content' (B.drop (B.length marker) after') + where + marker = "${" <> label <> "}" diff --git a/apps/smp-server/web/Static/Embedded.hs b/apps/smp-server/web/Static/Embedded.hs new file mode 100644 index 000000000..23698dd6f --- /dev/null +++ b/apps/smp-server/web/Static/Embedded.hs @@ -0,0 +1,15 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Static.Embedded where + +import Data.FileEmbed (embedDir, embedFile) +import Data.ByteString (ByteString) + +indexHtml :: ByteString +indexHtml = $(embedFile "apps/smp-server/static/index.html") + +linkHtml :: ByteString +linkHtml = $(embedFile "apps/smp-server/static/link.html") + +mediaContent :: [(FilePath, ByteString)] +mediaContent = $(embedDir "apps/smp-server/static/media/") diff --git a/package.yaml b/package.yaml index 02095d95f..68b93c1ac 100644 --- a/package.yaml +++ b/package.yaml @@ -22,6 +22,8 @@ extra-source-files: - CHANGELOG.md - cbits/sha512.h - cbits/sntrup761.h + - apps/smp-server/static/*.html + - apps/smp-server/static/media/* dependencies: - aeson == 2.2.* @@ -110,10 +112,16 @@ library: executables: smp-server: - source-dirs: apps/smp-server + source-dirs: + - apps/smp-server + - apps/smp-server/web main: Main.hs dependencies: + - file-embed - simplexmq + - wai-app-static + - warp + - warp-tls ghc-options: - -threaded - -rtsopts diff --git a/rfcs/2024-03-20-server-metadata.md b/rfcs/2024-03-20-server-metadata.md index f8795f130..22b163c05 100644 --- a/rfcs/2024-03-20-server-metadata.md +++ b/rfcs/2024-03-20-server-metadata.md @@ -52,8 +52,8 @@ source_code: https://github.com/simplex-chat/simplexmq # We should split this document to the model one, where specific parameters will be external to the document, # and specific to us, so that relay operators can adopt our recommended policy and publish any amendments separately. -conditions: https://github.com/simplex-chat/simplex-chat/blob/_archived-ep/ios-file-provider/PRIVACY.md -# conditions_amendments: link +usage_conditions: https://github.com/simplex-chat/simplex-chat/blob/_archived-ep/ios-file-provider/PRIVACY.md +# condition_amendments: link server_country: SE operator: SimpleX Chat Ltd. @@ -62,9 +62,9 @@ website: https://simplex.chat admin_simplex: administrative SimpleX address admin_email: chat@simplex.chat admin_pgp: PGP key -feedback_simplex: SimpleX address for feedback, comments and complaints -feedback_email: complaints@simplex.chat -feedback_pgp: PGP key +complaints_simplex: SimpleX address for feedback, comments and complaints +complaints_email: complaints@simplex.chat +complaints_pgp: PGP key hosting: Linode / Akamai Inc. hosting_country: US ``` @@ -89,20 +89,27 @@ data ServerHandshake = ServerHandshake } data ServerInformation = ServerInformation - { -- below is based on the existing server configuration - persistence :: SMPServerPersistenceMode, + { config :: ServerPublicConfig, + info :: ServerPublicInfo + } + +-- based on server configuration +data ServerPublicConfig = ServerPublicConfig + { persistence :: SMPServerPersistenceMode, messageExpiration :: Int, statsEnabled :: Bool, newQueuesAllowed :: Bool, - basicAuthEnabled :: Bool, -- server is private if enabled - -- below is based on INFORMATION section of INI file - sourceCode :: Text, -- note that this property is not optional, in line with AGPLv3 license - -- all below properties are optional, except entity name MUST be present if any entity country is present + basicAuthEnabled :: Bool -- server is private if enabled + } + +-- based on INFORMATION section of INI file +data ServerPublicInfo = ServerPublicInfo + { sourceCode :: Text, -- note that this property is not optional, in line with AGPLv3 license conditions :: Maybe ServerConditions, operator :: Maybe Entity, website :: Maybe Text, - admin :: Maybe ServerContactAddress, - feedback :: Maybe ServerContactAddress, + adminContacts :: Maybe ServerContactAddress, + complaintsContacts :: Maybe ServerContactAddress, hosting :: Maybe Entity, serverCountry :: Maybe Text } diff --git a/simplexmq.cabal b/simplexmq.cabal index 890ecf1e8..7f53a2ba1 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -28,6 +28,33 @@ extra-source-files: CHANGELOG.md cbits/sha512.h cbits/sntrup761.h + apps/smp-server/static/index.html + apps/smp-server/static/link.html + apps/smp-server/static/media/apk_icon.png + apps/smp-server/static/media/apple_store.svg + apps/smp-server/static/media/contact.js + apps/smp-server/static/media/contact_page_mobile.png + apps/smp-server/static/media/f_droid.svg + apps/smp-server/static/media/favicon.ico + apps/smp-server/static/media/GilroyBold.woff2 + apps/smp-server/static/media/GilroyLight.woff2 + apps/smp-server/static/media/GilroyMedium.woff2 + apps/smp-server/static/media/GilroyRegular.woff2 + apps/smp-server/static/media/GilroyRegularItalic.woff2 + apps/smp-server/static/media/google_play.svg + apps/smp-server/static/media/logo-dark.png + apps/smp-server/static/media/logo-light.png + apps/smp-server/static/media/logo-symbol-dark.svg + apps/smp-server/static/media/logo-symbol-light.svg + apps/smp-server/static/media/moon.svg + apps/smp-server/static/media/qrcode.js + apps/smp-server/static/media/script.js + apps/smp-server/static/media/style.css + apps/smp-server/static/media/sun.svg + apps/smp-server/static/media/swiper-bundle.min.css + apps/smp-server/static/media/swiper-bundle.min.js + apps/smp-server/static/media/tailwind.css + apps/smp-server/static/media/testflight.png flag swift description: Enable swift JSON format @@ -140,6 +167,7 @@ library Simplex.Messaging.Server.Control Simplex.Messaging.Server.Env.STM Simplex.Messaging.Server.Expiration + Simplex.Messaging.Server.Information Simplex.Messaging.Server.Main Simplex.Messaging.Server.MsgStore Simplex.Messaging.Server.MsgStore.STM @@ -401,9 +429,12 @@ executable smp-agent executable smp-server main-is: Main.hs other-modules: + Static + Static.Embedded Paths_simplexmq hs-source-dirs: apps/smp-server + apps/smp-server/web default-extensions: StrictData ghc-options: -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=incomplete-uni-patterns -Werror=missing-methods -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -O2 -threaded -rtsopts @@ -428,6 +459,7 @@ executable smp-server , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* + , file-embed , filepath ==1.4.* , hourglass ==0.2.* , http-types ==0.12.* @@ -456,6 +488,9 @@ executable smp-server , transformers ==0.6.* , unliftio ==0.2.* , unliftio-core ==0.2.* + , wai-app-static + , warp + , warp-tls , websockets ==0.12.* , yaml ==0.11.* , zstd ==0.1.3.* diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index b909b1617..ee9a52d34 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -7,13 +7,13 @@ module Simplex.FileTransfer.Server.Main where -import qualified Data.ByteString.Char8 as B import Data.Either (fromRight) import Data.Functor (($>)) import Data.Ini (lookupValue, readIniFile) import Data.Int (Int64) import Data.Maybe (fromMaybe) import qualified Data.Text as T +import qualified Data.Text.IO as T import Network.Socket (HostName) import Options.Applicative import Simplex.FileTransfer.Chunks @@ -29,6 +29,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig (..), defaultTransportServerConfig) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath (combine) import System.IO (BufferMode (..), hSetBuffering, stderr, stdout) @@ -69,7 +70,7 @@ xftpServerCLI cfgPath logPath = do fp <- createServerX509 cfgPath x509cfg let host = fromMaybe (if ip == "127.0.0.1" then "" else ip) fqdn srv = ProtoServerWithAuth (XFTPServer [THDomainName host] "" (C.KeyHash fp)) Nothing - writeFile iniFile $ iniFileContent host + T.writeFile iniFile $ iniFileContent host putStrLn $ "Server initialized, you can modify configuration in " <> iniFile <> ".\nRun `" <> executableName <> " start` to start server." warnCAPrivateKeyFile cfgPath x509cfg printServiceInfo serverVersion srv @@ -83,7 +84,7 @@ xftpServerCLI cfgPath logPath = do \# Log is compacted on start (deleted objects are removed).\n" <> ("enable: " <> onOff enableStoreLog <> "\n\n") <> "# Expire files after the specified number of hours.\n" - <> ("expire_files_hours: " <> show defFileExpirationHours <> "\n\n") + <> ("expire_files_hours: " <> tshow defFileExpirationHours <> "\n\n") <> "log_stats: off\n\ \\n\ \[AUTH]\n\ @@ -102,20 +103,20 @@ xftpServerCLI cfgPath logPath = do \# control_port_user_password:\n\ \[TRANSPORT]\n\ \# host is only used to print server address on start\n" - <> ("host: " <> host <> "\n") - <> ("port: " <> defaultServerPort <> "\n") + <> ("host: " <> T.pack host <> "\n") + <> ("port: " <> T.pack defaultServerPort <> "\n") <> "log_tls_errors: off\n\ \# control_port: 5226\n\ \\n\ \[FILES]\n" - <> ("path: " <> filesPath <> "\n") - <> ("storage_quota: " <> B.unpack (strEncode fileSizeQuota) <> "\n") + <> ("path: " <> T.pack filesPath <> "\n") + <> ("storage_quota: " <> safeDecodeUtf8 (strEncode fileSizeQuota) <> "\n") <> "\n\ \[INACTIVE_CLIENTS]\n\ \# TTL and interval to check inactive clients\n\ \disconnect: off\n" - <> ("# ttl: " <> show (ttl defaultInactiveClientExpiration) <> "\n") - <> ("# check_interval: " <> show (checkInterval defaultInactiveClientExpiration) <> "\n") + <> ("# ttl: " <> tshow (ttl defaultInactiveClientExpiration) <> "\n") + <> ("# check_interval: " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n") runServer ini = do hSetBuffering stdout LineBuffering hSetBuffering stderr LineBuffering diff --git a/src/Simplex/Messaging/Agent/Protocol.hs b/src/Simplex/Messaging/Agent/Protocol.hs index 0b40edee9..e44c2c0f7 100644 --- a/src/Simplex/Messaging/Agent/Protocol.hs +++ b/src/Simplex/Messaging/Agent/Protocol.hs @@ -108,6 +108,7 @@ module Simplex.Messaging.Agent.Protocol CRClientData, ServiceScheme, simplexChat, + connReqUriP', AgentErrorType (..), CommandErrorType (..), ConnectionErrorType (..), @@ -1161,7 +1162,7 @@ instance StrEncoding MsgReceipt where msgRcptStatus <- strP pure MsgReceipt {agentMsgId, msgRcptStatus} -instance forall m. ConnectionModeI m => StrEncoding (ConnectionRequestUri m) where +instance ConnectionModeI m => StrEncoding (ConnectionRequestUri m) where strEncode = \case CRInvitationUri crData e2eParams -> crEncode "invitation" crData (Just e2eParams) CRContactUri crData -> crEncode "contact" crData Nothing @@ -1175,34 +1176,40 @@ instance forall m. ConnectionModeI m => StrEncoding (ConnectionRequestUri m) whe [("v", strEncode crAgentVRange), ("smp", strEncode crSmpQueues)] <> maybe [] (\e2e -> [("e2e", strEncode e2e)]) e2eParams <> maybe [] (\cd -> [("data", encodeUtf8 cd)]) crClientData - strP = do - ACR m cr <- strP - case testEquality m $ sConnectionMode @m of - Just Refl -> pure cr - _ -> fail "bad connection request mode" + strP = connReqUriP' (Just SSSimplex) + +connReqUriP' :: forall m. ConnectionModeI m => Maybe ServiceScheme -> Parser (ConnectionRequestUri m) +connReqUriP' overrideScheme = do + ACR m cr <- connReqUriP overrideScheme + case testEquality m $ sConnectionMode @m of + Just Refl -> pure cr + _ -> fail "bad connection request mode" instance StrEncoding AConnectionRequestUri where strEncode (ACR _ cr) = strEncode cr - strP = do - _crScheme :: ServiceScheme <- strP - crMode <- A.char '/' *> crModeP <* optional (A.char '/') <* "#/?" - query <- strP - aVRange <- queryParam "v" query - crSmpQueues <- queryParam "smp" query - let crClientData = safeDecodeUtf8 <$> queryParamStr "data" query - let crData = ConnReqUriData {crScheme = SSSimplex, crAgentVRange = aVRange, crSmpQueues, crClientData} - case crMode of - CMInvitation -> do - crE2eParams <- queryParam "e2e" query - pure . ACR SCMInvitation $ CRInvitationUri crData crE2eParams - -- contact links are adjusted to the minimum version supported by the agent - -- to preserve compatibility with the old links published online - CMContact -> pure . ACR SCMContact $ CRContactUri crData {crAgentVRange = adjustAgentVRange aVRange} - where - crModeP = "invitation" $> CMInvitation <|> "contact" $> CMContact - adjustAgentVRange vr = - let v = max duplexHandshakeSMPAgentVersion $ minVersion vr - in fromMaybe vr $ safeVersionRange v (max v $ maxVersion vr) + strP = connReqUriP (Just SSSimplex) + +connReqUriP :: Maybe ServiceScheme -> Parser AConnectionRequestUri +connReqUriP overrideScheme = do + crScheme <- (`fromMaybe` overrideScheme) <$> strP + crMode <- A.char '/' *> crModeP <* optional (A.char '/') <* "#/?" + query <- strP + aVRange <- queryParam "v" query + crSmpQueues <- queryParam "smp" query + let crClientData = safeDecodeUtf8 <$> queryParamStr "data" query + crData = ConnReqUriData {crScheme, crAgentVRange = aVRange, crSmpQueues, crClientData} + case crMode of + CMInvitation -> do + crE2eParams <- queryParam "e2e" query + pure . ACR SCMInvitation $ CRInvitationUri crData crE2eParams + -- contact links are adjusted to the minimum version supported by the agent + -- to preserve compatibility with the old links published online + CMContact -> pure . ACR SCMContact $ CRContactUri crData {crAgentVRange = adjustAgentVRange aVRange} + where + crModeP = "invitation" $> CMInvitation <|> "contact" $> CMContact + adjustAgentVRange vr = + let v = max duplexHandshakeSMPAgentVersion $ minVersion vr + in fromMaybe vr $ safeVersionRange v (max v $ maxVersion vr) instance ConnectionModeI m => FromJSON (ConnectionRequestUri m) where parseJSON = strParseJSON "ConnectionRequestUri" diff --git a/src/Simplex/Messaging/Notifications/Server/Main.hs b/src/Simplex/Messaging/Notifications/Server/Main.hs index 2d2f0bfc4..351fe6d72 100644 --- a/src/Simplex/Messaging/Notifications/Server/Main.hs +++ b/src/Simplex/Messaging/Notifications/Server/Main.hs @@ -11,6 +11,7 @@ import Data.Functor (($>)) import Data.Ini (lookupValue, readIniFile) import Data.Maybe (fromMaybe) import qualified Data.Text as T +import qualified Data.Text.IO as T import Network.Socket (HostName) import Options.Applicative import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) @@ -25,6 +26,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig (..), defaultTransportServerConfig) +import Simplex.Messaging.Util (tshow) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath (combine) import System.IO (BufferMode (..), hSetBuffering, stderr, stdout) @@ -65,7 +67,7 @@ ntfServerCLI cfgPath logPath = fp <- createServerX509 cfgPath x509cfg let host = fromMaybe (if ip == "127.0.0.1" then "" else ip) fqdn srv = ProtoServerWithAuth (NtfServer [THDomainName host] "" (C.KeyHash fp)) Nothing - writeFile iniFile $ iniFileContent host + T.writeFile iniFile $ iniFileContent host putStrLn $ "Server initialized, you can modify configuration in " <> iniFile <> ".\nRun `" <> executableName <> " start` to start server." warnCAPrivateKeyFile cfgPath x509cfg printServiceInfo serverVersion srv @@ -81,15 +83,15 @@ ntfServerCLI cfgPath logPath = <> "log_stats: off\n\n\ \[TRANSPORT]\n\ \# host is only used to print server address on start\n" - <> ("host: " <> host <> "\n") - <> ("port: " <> defaultServerPort <> "\n") + <> ("host: " <> T.pack host <> "\n") + <> ("port: " <> T.pack defaultServerPort <> "\n") <> "log_tls_errors: off\n" <> "websockets: off\n\n\ \[INACTIVE_CLIENTS]\n\ \# TTL and interval to check inactive clients\n\ \disconnect: off\n" - <> ("# ttl: " <> show (ttl defaultInactiveClientExpiration) <> "\n") - <> ("# check_interval: " <> show (checkInterval defaultInactiveClientExpiration) <> "\n") + <> ("# ttl: " <> tshow (ttl defaultInactiveClientExpiration) <> "\n") + <> ("# check_interval: " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n") runServer ini = do hSetBuffering stdout LineBuffering hSetBuffering stderr LineBuffering diff --git a/src/Simplex/Messaging/Server/CLI.hs b/src/Simplex/Messaging/Server/CLI.hs index 9531a2ca5..956b30816 100644 --- a/src/Simplex/Messaging/Server/CLI.hs +++ b/src/Simplex/Messaging/Server/CLI.hs @@ -254,7 +254,7 @@ onOffPrompt prompt def = "N" -> pure False _ -> putStrLn "Invalid input, please enter 'y' or 'n'" >> onOffPrompt prompt def -onOff :: Bool -> String +onOff :: Bool -> Text onOff True = "on" onOff _ = "off" diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index f602c890b..4217ea9b9 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StrictData #-} module Simplex.Messaging.Server.Env.STM where @@ -15,6 +16,7 @@ import qualified Data.IntMap.Strict as IM import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Maybe (isJust, isNothing) import Data.Time.Clock (getCurrentTime) import Data.Time.Clock.System (SystemTime) import Data.X509.Validation (Fingerprint (..)) @@ -27,6 +29,7 @@ import Simplex.Messaging.Crypto (KeyHash (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol import Simplex.Messaging.Server.Expiration +import Simplex.Messaging.Server.Information import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.QueueStore (NtfCreds (..), QueueRec (..)) import Simplex.Messaging.Server.QueueStore.STM @@ -81,9 +84,12 @@ data ServerConfig = ServerConfig transportConfig :: TransportServerConfig, -- | run listener on control port controlPort :: Maybe ServiceName, + -- | SMP proxy config smpAgentCfg :: SMPClientAgentConfig, allowSMPProxy :: Bool, -- auth is the same with `newQueueBasicAuth` - serverClientConcurrency :: Int + serverClientConcurrency :: Int, + -- | server public information + information :: Maybe ServerPublicInfo } defMsgExpirationDays :: Int64 @@ -108,6 +114,7 @@ defaultProxyClientConcurrency = 32 data Env = Env { config :: ServerConfig, + serverInfo :: ServerInformation, server :: Server, serverIdentity :: KeyHash, queueStore :: QueueStore, @@ -192,7 +199,7 @@ newSubscription subThread = do return Sub {subThread, delivered} newEnv :: ServerConfig -> IO Env -newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, storeLogFile, smpAgentCfg, transportConfig} = do +newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, storeLogFile, smpAgentCfg, transportConfig, information, messageExpiration} = do server <- atomically newServer queueStore <- atomically newQueueStore msgStore <- atomically newMsgStore @@ -206,7 +213,7 @@ newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, clientSeq <- newTVarIO 0 clients <- newTVarIO mempty proxyAgent <- atomically $ newSMPProxyAgent smpAgentCfg random - return Env {config, server, serverIdentity, queueStore, msgStore, random, storeLog, tlsServerParams, serverStats, sockets, clientSeq, clients, proxyAgent} + pure Env {config, serverInfo, server, serverIdentity, queueStore, msgStore, random, storeLog, tlsServerParams, serverStats, sockets, clientSeq, clients, proxyAgent} where restoreQueues :: QueueStore -> FilePath -> IO (StoreLog 'WriteMode) restoreQueues QueueStore {queues, senders, notifiers} f = do @@ -222,6 +229,23 @@ newEnv config@ServerConfig {caCertificateFile, certificateFile, privateKeyFile, addNotifier q = case notifier q of Nothing -> id Just NtfCreds {notifierId} -> M.insert notifierId (recipientId q) + serverInfo = + ServerInformation + { information, + config = + ServerPublicConfig + { persistence, + messageExpiration = ttl <$> messageExpiration, + statsEnabled = isJust $ logStatsInterval config, + newQueuesAllowed = allowNewQueues config, + basicAuthEnabled = isJust $ newQueueBasicAuth config + } + } + where + persistence + | isNothing storeLogFile = SPMMemoryOnly + | isJust (storeMsgsFile config) = SPMMessages + | otherwise = SPMQueues newSMPProxyAgent :: SMPClientAgentConfig -> TVar ChaChaDRG -> STM ProxyAgent newSMPProxyAgent smpAgentCfg random = do diff --git a/src/Simplex/Messaging/Server/Information.hs b/src/Simplex/Messaging/Server/Information.hs new file mode 100644 index 000000000..01052541d --- /dev/null +++ b/src/Simplex/Messaging/Server/Information.hs @@ -0,0 +1,76 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Messaging.Server.Information where + +import qualified Data.Aeson.TH as J +import Data.Int (Int64) +import Data.Text (Text) +import Simplex.Messaging.Agent.Protocol (ConnectionMode (..), ConnectionRequestUri) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) + +data ServerInformation = ServerInformation + { config :: ServerPublicConfig, + information :: Maybe ServerPublicInfo + } + deriving (Show) + +-- based on server configuration +data ServerPublicConfig = ServerPublicConfig + { persistence :: ServerPersistenceMode, + messageExpiration :: Maybe Int64, + statsEnabled :: Bool, + newQueuesAllowed :: Bool, + basicAuthEnabled :: Bool -- server is private if enabled + } + deriving (Show) + +-- based on INFORMATION section of INI file +data ServerPublicInfo = ServerPublicInfo + { sourceCode :: Text, -- note that this property is not optional, in line with AGPLv3 license + usageConditions :: Maybe ServerConditions, + operator :: Maybe Entity, + website :: Maybe Text, + adminContacts :: Maybe ServerContactAddress, + complaintsContacts :: Maybe ServerContactAddress, + hosting :: Maybe Entity, + serverCountry :: Maybe Text + } + deriving (Show) + +data ServerPersistenceMode = SPMMemoryOnly | SPMQueues | SPMMessages + deriving (Show) + +data ServerConditions = ServerConditions {conditions :: Text, amendments :: Maybe Text} + deriving (Show) + +data Entity = Entity {name :: Text, country :: Maybe Text} + deriving (Show) + +data ServerContactAddress = ServerContactAddress + { simplex :: Maybe (ConnectionRequestUri 'CMContact), + email :: Maybe Text, -- it is recommended that it matches DNS email address, if either is present + pgp :: Maybe PGPKey + } + deriving (Show) + +data PGPKey = PGPKey {pkURI :: Text, pkFingerprint :: Text} + deriving (Show) + +$(J.deriveJSON (enumJSON $ dropPrefix "SPM") ''ServerPersistenceMode) + +$(J.deriveJSON defaultJSON ''ServerConditions) + +$(J.deriveJSON defaultJSON ''Entity) + +$(J.deriveJSON defaultJSON ''PGPKey) + +$(J.deriveJSON defaultJSON ''ServerContactAddress) + +$(J.deriveJSON defaultJSON ''ServerPublicConfig) + +$(J.deriveJSON defaultJSON ''ServerPublicInfo) + +$(J.deriveJSON defaultJSON ''ServerInformation) diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index 980a7b8d0..7af57ba25 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -5,34 +5,45 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StrictData #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Messaging.Server.Main where import Control.Concurrent.STM -import Control.Monad (void) +import Control.Logger.Simple +import Control.Monad (void, when, (<$!>)) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Char (isAlpha, isAscii, toUpper) import Data.Functor (($>)) -import Data.Ini (lookupValue, readIniFile) -import Data.Maybe (fromMaybe) +import Data.Ini (Ini, lookupValue, readIniFile) +import Data.List (find, isPrefixOf) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import qualified Data.Text.IO as T import Network.Socket (HostName) import Options.Applicative +import Simplex.Messaging.Agent.Protocol (connReqUriP') import Simplex.Messaging.Client (HostMode (..), NetworkConfig (..), ProtocolClientConfig (..), SocksMode (..), defaultNetworkConfig) import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (ProtoServerWithAuth), pattern SMPServer) import Simplex.Messaging.Server (runSMPServer) import Simplex.Messaging.Server.CLI import Simplex.Messaging.Server.Env.STM (ServerConfig (..), defMsgExpirationDays, defaultInactiveClientExpiration, defaultMessageExpiration, defaultProxyClientConcurrency) import Simplex.Messaging.Server.Expiration +import Simplex.Messaging.Server.Information import Simplex.Messaging.Transport (batchCmdsSMPVersion, sendingProxySMPVersion, simplexMQVersion, supportedSMPHandshakes, supportedServerSMPRelayVRange) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Server (TransportServerConfig (..), defaultTransportServerConfig) -import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (mkVersionRange) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath (combine) @@ -40,7 +51,10 @@ import System.IO (BufferMode (..), hSetBuffering, stderr, stdout) import Text.Read (readMaybe) smpServerCLI :: FilePath -> FilePath -> IO () -smpServerCLI cfgPath logPath = +smpServerCLI = smpServerCLI_ (\_ _ _ -> pure ()) (\_ -> pure ()) + +smpServerCLI_ :: (ServerInformation -> Maybe TransportHost -> FilePath -> IO ()) -> (EmbeddedWebParams -> IO ()) -> FilePath -> FilePath -> IO () +smpServerCLI_ generateSite serveStaticFiles cfgPath logPath = getCliCommand' (cliCommandP cfgPath logPath iniFile) serverVersion >>= \case Init opts -> doesFileExist iniFile >>= \case @@ -65,7 +79,10 @@ smpServerCLI cfgPath logPath = defaultServerPort = "5223" executableName = "smp-server" storeLogFilePath = combine logPath "smp-server-store.log" - initializeServer opts@InitOptions {ip, fqdn, scripted} + httpsCertFile = combine cfgPath "web.cert" + httpsKeyFile = combine cfgPath "web.key" + defaultStaticPath = combine logPath "www" + initializeServer opts@InitOptions {ip, fqdn, sourceCode = src', webStaticPath = sp', disableWeb = noWeb', scripted} | scripted = initialize opts | otherwise = do putStrLn "Use `smp-server init -h` for available options." @@ -76,7 +93,19 @@ smpServerCLI cfgPath logPath = password <- withPrompt "'r' for random (default), 'n' - no password, or enter password: " serverPassword let host = fromMaybe ip fqdn host' <- withPrompt ("Enter server FQDN or IP address for certificate (" <> host <> "): ") getLine - initialize opts {enableStoreLog, logStats, fqdn = if null host' then fqdn else Just host', password} + sourceCode' <- withPrompt ("Enter server source code URI (" <> maybe simplexmqSource T.unpack src' <> "): ") getServerSourceCode + staticPath' <- withPrompt ("Enter path to store generated static site with server information (" <> fromMaybe defaultStaticPath sp' <> "): ") getLine + enableWeb <- onOffPrompt "Enable built-in web server for static site" (not noWeb') + initialize + opts + { enableStoreLog, + logStats, + fqdn = if null host' then fqdn else Just host', + password, + sourceCode = (T.pack <$> sourceCode') <|> src', + webStaticPath = if null staticPath' then sp' else Just staticPath', + disableWeb = not enableWeb + } where serverPassword = getLine >>= \case @@ -87,7 +116,7 @@ smpServerCLI cfgPath logPath = case strDecode $ encodeUtf8 $ T.pack s of Right auth -> pure . Just $ ServerPassword auth _ -> putStrLn "Invalid password. Only latin letters, digits and symbols other than '@' and ':' are allowed" >> serverPassword - initialize InitOptions {enableStoreLog, logStats, signAlgorithm, password} = do + initialize InitOptions {enableStoreLog, logStats, signAlgorithm, password, sourceCode, webStaticPath, disableWeb} = do clearDirIfExists cfgPath clearDirIfExists logPath createDirectoryIfMissing True cfgPath @@ -97,26 +126,29 @@ smpServerCLI cfgPath logPath = basicAuth <- mapM createServerPassword password let host = fromMaybe (if ip == "127.0.0.1" then "" else ip) fqdn srv = ProtoServerWithAuth (SMPServer [THDomainName host] "" (C.KeyHash fp)) basicAuth - writeFile iniFile $ iniFileContent host basicAuth - putStrLn $ "Server initialized, you can modify configuration in " <> iniFile <> ".\nRun `" <> executableName <> " start` to start server." + T.writeFile iniFile $ iniFileContent host basicAuth $ Just "https://github.com/simplex-chat/simplexmq" + putStrLn $ "Server initialized, please provide additional server information in " <> iniFile <> "." + putStrLn $ "Run `" <> executableName <> " start` to start server." warnCAPrivateKeyFile cfgPath x509cfg printServiceInfo serverVersion srv + printSourceCode sourceCode where createServerPassword = \case ServerPassword s -> pure s SPRandom -> BasicAuth . strEncode <$> (atomically . C.randomBytes 32 =<< C.newRandom) - iniFileContent host basicAuth = - "[STORE_LOG]\n\ - \# The server uses STM memory for persistence,\n\ - \# that will be lost on restart (e.g., as with redis).\n\ - \# This option enables saving memory to append only log,\n\ - \# and restoring it when the server is started.\n\ - \# Log is compacted on start (deleted objects are removed).\n" + iniFileContent host basicAuth sourceCode' = + informationIniContent sourceCode' + <> "[STORE_LOG]\n\ + \# The server uses STM memory for persistence,\n\ + \# that will be lost on restart (e.g., as with redis).\n\ + \# This option enables saving memory to append only log,\n\ + \# and restoring it when the server is started.\n\ + \# Log is compacted on start (deleted objects are removed).\n" <> ("enable: " <> onOff enableStoreLog <> "\n\n") <> "# Undelivered messages are optionally saved and restored when the server restarts,\n\ \# they are preserved in the .bak file until the next restart.\n" <> ("restore_messages: " <> onOff enableStoreLog <> "\n") - <> ("expire_messages_days: " <> show defMsgExpirationDays <> "\n\n") + <> ("expire_messages_days: " <> tshow defMsgExpirationDays <> "\n\n") <> "# Log daily server statistics to CSV file\n" <> ("log_stats: " <> onOff logStats <> "\n\n") <> "[AUTH]\n\ @@ -129,7 +161,7 @@ smpServerCLI cfgPath logPath = \# The password will not be shared with the connecting contacts, you must share it only\n\ \# with the users who you want to allow creating messaging queues on your server.\n" <> ( case basicAuth of - Just auth -> "create_password: " <> T.unpack (safeDecodeUtf8 $ strEncode auth) + Just auth -> "create_password: " <> safeDecodeUtf8 (strEncode auth) _ -> "# create_password: password to create new queues (any printable ASCII characters without whitespace, '@', ':' and '/')" ) <> "\n\n\ @@ -137,8 +169,8 @@ smpServerCLI cfgPath logPath = \# control_port_user_password:\n\n\ \[TRANSPORT]\n\ \# host is only used to print server address on start\n" - <> ("host: " <> host <> "\n") - <> ("port: " <> defaultServerPort <> "\n") + <> ("host: " <> T.pack host <> "\n") + <> ("port: " <> T.pack defaultServerPort <> "\n") <> "log_tls_errors: off\n\ \websockets: off\n\ \# control_port: 5224\n\n\ @@ -157,21 +189,36 @@ smpServerCLI cfgPath logPath = \# or 'always' to be used for all destination hosts (can be used if it is an .onion server).\n\ \# socks_mode: onion\n\n\ \# Limit number of threads a client can spawn to process proxy commands in parrallel.\n" - <> ("# client_concurrency: " <> show defaultProxyClientConcurrency <> "\n\n") + <> ("# client_concurrency: " <> tshow defaultProxyClientConcurrency <> "\n\n") <> "[INACTIVE_CLIENTS]\n\ \# TTL and interval to check inactive clients\n\ \disconnect: off\n" - <> ("# ttl: " <> show (ttl defaultInactiveClientExpiration) <> "\n") - <> ("# check_interval: " <> show (checkInterval defaultInactiveClientExpiration) <> "\n") + <> ("# ttl: " <> tshow (ttl defaultInactiveClientExpiration) <> "\n") + <> ("# check_interval: " <> tshow (checkInterval defaultInactiveClientExpiration) <> "\n") + <> "\n\n\ + \[WEB]\n\ + \# Set path to generate static mini-site for server information and qr codes/links\n" + <> ("static_path: " <> T.pack (fromMaybe defaultStaticPath webStaticPath) <> "\n\n") + <> "# Run an embedded server on this port\n\ + \# Onion sites can use any port and register it in the hidden service config.\n\ + \# Running on a port 80 may require setting process capabilities.\n" + <> ((if disableWeb then "# " else "") <> "http: 8000\n\n") + <> "# You can run an embedded TLS web server too if you provide port and cert and key files.\n\ + \# Not required for running relay on onion address.\n\ + \# https: 443\n" + <> ("# cert: " <> T.pack httpsCertFile <> "\n") + <> ("# key: " <> T.pack httpsKeyFile <> "\n") runServer ini = do hSetBuffering stdout LineBuffering hSetBuffering stderr LineBuffering fp <- checkSavedFingerprint cfgPath defaultX509Config let host = either (const "") T.unpack $ lookupValue "TRANSPORT" "host" ini port = T.unpack $ strictIni "TRANSPORT" "port" ini - cfg@ServerConfig {transports, storeLogFile, newQueueBasicAuth, messageExpiration, inactiveClientExpiration} = serverConfig + cfg@ServerConfig {information, transports, storeLogFile, newQueueBasicAuth, messageExpiration, inactiveClientExpiration} = serverConfig + sourceCode' = (\ServerPublicInfo {sourceCode} -> sourceCode) <$> information srv = ProtoServerWithAuth (SMPServer [THDomainName host] (if port == "5223" then "" else port) (C.KeyHash fp)) newQueueBasicAuth printServiceInfo serverVersion srv + printSourceCode sourceCode' printServerConfig transports storeLogFile putStrLn $ case messageExpiration of Just ExpirationConfig {ttl} -> "expiring messages after " <> showTTL ttl @@ -184,6 +231,20 @@ smpServerCLI cfgPath logPath = <> if allowNewQueues cfg then maybe "allowed" (const "requires password") newQueueBasicAuth else "NOT allowed" + -- print information + let persistence + | isNothing storeLogFile = SPMMemoryOnly + | isJust (storeMsgsFile cfg) = SPMMessages + | otherwise = SPMQueues + let config = + ServerPublicConfig + { persistence, + messageExpiration = ttl <$> messageExpiration, + statsEnabled = isJust logStats, + newQueuesAllowed = allowNewQueues cfg, + basicAuthEnabled = isJust newQueueBasicAuth + } + runWebServer ini ServerInformation {config, information} runSMPServer cfg where enableStoreLog = settingIsOn "STORE_LOG" "enable" ini @@ -211,9 +272,9 @@ smpServerCLI cfgPath logPath = _ -> enableStoreLog $> messagesPath, -- allow creating new queues by default allowNewQueues = fromMaybe True $ iniOnOff "AUTH" "new_queues" ini, - newQueueBasicAuth = either error id <$> strDecodeIni "AUTH" "create_password" ini, - controlPortAdminAuth = either error id <$> strDecodeIni "AUTH" "control_port_admin_password" ini, - controlPortUserAuth = either error id <$> strDecodeIni "AUTH" "control_port_user_password" ini, + newQueueBasicAuth = either error id <$!> strDecodeIni "AUTH" "create_password" ini, + controlPortAdminAuth = either error id <$!> strDecodeIni "AUTH" "control_port_admin_password" ini, + controlPortUserAuth = either error id <$!> strDecodeIni "AUTH" "control_port_user_password" ini, messageExpiration = Just defaultMessageExpiration @@ -235,7 +296,7 @@ smpServerCLI cfgPath logPath = { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini, alpn = Just supportedSMPHandshakes }, - controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini, + controlPort = eitherToMaybe $ T.unpack <$> lookupValue "TRANSPORT" "control_port" ini, smpAgentCfg = defaultSMPClientAgentConfig { smpCfg = @@ -244,7 +305,7 @@ smpServerCLI cfgPath logPath = agreeSecret = True, networkConfig = defaultNetworkConfig - { socksProxy = either error id <$> strDecodeIni "PROXY" "socks_proxy" ini, + { socksProxy = either error id <$!> strDecodeIni "PROXY" "socks_proxy" ini, socksMode = either (const SMOnion) textToSocksMode $ lookupValue "PROXY" "socks_mode" ini, hostMode = either (const HMPublic) textToHostMode $ lookupValue "PROXY" "host_mode" ini, requiredHostMode = fromMaybe False $ iniOnOff "PROXY" "required_host_mode" ini @@ -254,7 +315,8 @@ smpServerCLI cfgPath logPath = persistErrorInterval = 30 -- seconds }, allowSMPProxy = True, - serverClientConcurrency = readIniDefault defaultProxyClientConcurrency "PROXY" "client_concurrency" ini + serverClientConcurrency = readIniDefault defaultProxyClientConcurrency "PROXY" "client_concurrency" ini, + information = serverPublicInfo ini } textToSocksMode :: Text -> SocksMode textToSocksMode = \case @@ -269,6 +331,126 @@ smpServerCLI cfgPath logPath = textToOwnServers :: Text -> [ByteString] textToOwnServers = map encodeUtf8 . T.words + runWebServer ini si = + case eitherToMaybe $ T.unpack <$> lookupValue "WEB" "static_path" ini of + Nothing -> logWarn "No server static path set" + Just webStaticPath -> do + let onionHost = + either (const Nothing) (find isOnion) $ + strDecode @(L.NonEmpty TransportHost) . encodeUtf8 =<< lookupValue "TRANSPORT" "host" ini + webHttpPort = eitherToMaybe $ read . T.unpack <$> lookupValue "WEB" "http" ini + webHttpsParams = + eitherToMaybe $ do + port <- read . T.unpack <$> lookupValue "WEB" "https" ini + cert <- T.unpack <$> lookupValue "WEB" "cert" ini + key <- T.unpack <$> lookupValue "WEB" "key" ini + pure WebHttpsParams {port, cert, key} + generateSite si onionHost webStaticPath + when (isJust webHttpPort || isJust webHttpsParams) $ + serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams} + where + isOnion = \case THOnionHost _ -> True; _ -> False + +data EmbeddedWebParams = EmbeddedWebParams + { webStaticPath :: FilePath, + webHttpPort :: Maybe Int, + webHttpsParams :: Maybe WebHttpsParams + } + +data WebHttpsParams = WebHttpsParams + { port :: Int, + cert :: FilePath, + key :: FilePath + } + +getServerSourceCode :: IO (Maybe String) +getServerSourceCode = + getLine >>= \case + "" -> pure Nothing + s | "https://" `isPrefixOf` s || "http://" `isPrefixOf` s -> pure $ Just s + _ -> putStrLn "Invalid source code. URI should start from http:// or https://" >> getServerSourceCode + +simplexmqSource :: String +simplexmqSource = "https://github.com/simplex-chat/simplexmq" + +informationIniContent :: Maybe Text -> Text +informationIniContent sourceCode_ = + "[INFORMATION]\n\ + \# AGPLv3 license requires that you make any source code modifications\n\ + \# available to the end users of the server.\n\ + \# LICENSE: https://github.com/simplex-chat/simplexmq/blob/stable/LICENSE\n\ + \# Include correct source code URI in case the server source code is modified in any way.\n\ + \# If any other information fields are present, source code property also MUST be present.\n\n" + <> (maybe "# source_code: URI" ("source_code: " <>) sourceCode_ <> "\n\n") + <> "# Declaring all below information is optional, any of these fields can be omitted.\n\ + \\n\ + \# Server usage conditions and amendments.\n\ + \# It is recommended to use standard conditions with any amendments in a separate document.\n\ + \# usage_conditions: https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md\n\ + \# condition_amendments: link\n\ + \\n\ + \# Server location and operator.\n\ + \# server_country: ISO-3166 2-letter code\n\ + \# operator: entity (organization or person name)\n\ + \# operator_country: ISO-3166 2-letter code\n\ + \# website:\n\ + \\n\ + \# Administrative contacts.\n\ + \# admin_simplex: SimpleX address\n\ + \# admin_email:\n\ + \# admin_pgp:\n\ + \# admin_pgp_fingerprint:\n\ + \\n\ + \# Contacts for complaints and feedback.\n\ + \# complaints_simplex: SimpleX address\n\ + \# complaints_email:\n\ + \# complaints_pgp:\n\ + \# complaints_pgp_fingerprint:\n\ + \\n\ + \# Hosting provider.\n\ + \# hosting: entity (organization or person name)\n\ + \# hosting_country: ISO-3166 2-letter code\n\n" + +serverPublicInfo :: Ini -> Maybe ServerPublicInfo +serverPublicInfo ini = serverInfo <$!> infoValue "source_code" + where + serverInfo sourceCode = + ServerPublicInfo + { sourceCode, + usageConditions = + (\conditions -> ServerConditions {conditions, amendments = infoValue "condition_amendments"}) + <$!> infoValue "usage_conditions", + serverCountry = countryValue "server_country", + operator = iniEntity "operator" "operator_country", + website = infoValue "website", + adminContacts = iniContacts "admin_simplex" "admin_email" "admin_pgp" "admin_pgp_fingerprint", + complaintsContacts = iniContacts "complaints_simplex" "complaints_email" "complaints_pgp" "complaints_pgp_fingerprint", + hosting = iniEntity "hosting" "hosting_country" + } + infoValue name = eitherToMaybe $ lookupValue "INFORMATION" name ini + iniEntity nameField countryField = + (\name -> Entity {name, country = countryValue countryField}) + <$!> infoValue nameField + countryValue field = + (\cs -> if T.length cs == 2 && T.all (\c -> isAscii c && isAlpha c) cs then T.map toUpper cs else error $ "Use ISO3166 2-letter code for " <> T.unpack field) + <$!> infoValue field + iniContacts simplexField emailField pgpKeyUriField pgpKeyFingerprintField = + let simplex = either error id . parseAll (connReqUriP' Nothing) . encodeUtf8 <$!> eitherToMaybe (lookupValue "INFORMATION" simplexField ini) + email = infoValue emailField + pkURI_ = infoValue pgpKeyUriField + pkFingerprint_ = infoValue pgpKeyFingerprintField + in case (simplex, email, pkURI_, pkFingerprint_) of + (Nothing, Nothing, Nothing, _) -> Nothing + (Nothing, Nothing, _, Nothing) -> Nothing + (_, _, pkURI, pkFingerprint) -> Just ServerContactAddress {simplex, email, pgp = PGPKey <$> pkURI <*> pkFingerprint} + +printSourceCode :: Maybe Text -> IO () +printSourceCode = \case + Just sourceCode -> T.putStrLn $ "Server source code: " <> sourceCode + Nothing -> do + putStrLn "Warning: server source code is not specified." + putStrLn "Add 'source_code' property to [INFORMATION] section of INI file." + data CliCommand = Init InitOptions | OnlineCert CertOptions @@ -282,6 +464,9 @@ data InitOptions = InitOptions ip :: HostName, fqdn :: Maybe HostName, password :: Maybe ServerPassword, + sourceCode :: Maybe Text, + webStaticPath :: Maybe FilePath, + disableWeb :: Bool, scripted :: Bool } deriving (Show) @@ -336,7 +521,6 @@ cliCommandP cfgPath logPath iniFile = ( long "fqdn" <> short 'n' <> help "Server FQDN used as Common Name for TLS online certificate" - <> showDefault <> metavar "FQDN" ) password <- @@ -349,12 +533,41 @@ cliCommandP cfgPath logPath iniFile = <> help "Set password to create new messaging queues" <> value SPRandom ) + sourceCode <- + (optional . strOption) + ( long "source-code" + <> help "Server source code will be communicated to the users" + <> metavar "URI" + ) + webStaticPath <- + (optional . strOption) + ( long "web-path" + <> help "Directory to store generated static site with server information" + <> metavar "PATH" + ) + disableWeb <- + switch + ( long "disable-web" + <> help "Disable starting static web server with server information" + ) scripted <- switch ( long "yes" <> short 'y' <> help "Non-interactive initialization using command-line options" ) - pure InitOptions {enableStoreLog, logStats, signAlgorithm, ip, fqdn, password, scripted} + pure + InitOptions + { enableStoreLog, + logStats, + signAlgorithm, + ip, + fqdn, + password, + sourceCode, + webStaticPath, + disableWeb, + scripted + } parseBasicAuth :: ReadM ServerPassword parseBasicAuth = eitherReader $ fmap ServerPassword . strDecode . B.pack diff --git a/tests/CLITests.hs b/tests/CLITests.hs index 3bc48c5ce..2193de5e9 100644 --- a/tests/CLITests.hs +++ b/tests/CLITests.hs @@ -57,7 +57,7 @@ smpServerTest :: Bool -> Bool -> IO () smpServerTest storeLog basicAuth = do -- init capture_ (withArgs (["init", "-y"] <> ["-l" | storeLog] <> ["--no-password" | not basicAuth]) $ smpServerCLI cfgPath logPath) - >>= (`shouldSatisfy` (("Server initialized, you can modify configuration in " <> cfgPath <> "/smp-server.ini") `isPrefixOf`)) + >>= (`shouldSatisfy` (("Server initialized, please provide additional server information in " <> cfgPath <> "/smp-server.ini") `isPrefixOf`)) Right ini <- readIniFile $ cfgPath <> "/smp-server.ini" lookupValue "STORE_LOG" "enable" ini `shouldBe` Right (if storeLog then "on" else "off") lookupValue "STORE_LOG" "log_stats" ini `shouldBe` Right "off" diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index f8c0e22c1..6bc36c29a 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -123,7 +123,8 @@ cfg = controlPort = Nothing, smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 1}, -- seconds allowSMPProxy = False, - serverClientConcurrency = 2 + serverClientConcurrency = 2, + information = Nothing } cfgV7 :: ServerConfig From 15f0bb9e7913f1f1ee037598c0367773c5a06942 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Wed, 29 May 2024 15:18:00 +0300 Subject: [PATCH 095/107] tcp-server: recover from accept errors (#1179) * tcp-server: recover from accept errors * log * warn * where * retry --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Messaging/Transport/Server.hs | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Transport/Server.hs b/src/Simplex/Messaging/Transport/Server.hs index 495ad76bb..ffde39991 100644 --- a/src/Simplex/Messaging/Transport/Server.hs +++ b/src/Simplex/Messaging/Transport/Server.hs @@ -19,7 +19,7 @@ module Simplex.Messaging.Transport.Server loadTLSServerParams, loadFingerprint, smpServerHandshake, - tlsServerCredentials + tlsServerCredentials, ) where @@ -35,11 +35,14 @@ import Data.Maybe (fromJust, fromMaybe) import qualified Data.X509 as X import Data.X509.Validation (Fingerprint (..)) import qualified Data.X509.Validation as XV +import Foreign.C.Error +import GHC.IO.Exception (ioe_errno) import Network.Socket import qualified Network.TLS as T import Simplex.Messaging.Transport import Simplex.Messaging.Util (catchAll_, labelMyThread, tshow) import System.Exit (exitFailure) +import System.IO.Error (tryIOError) import System.Mem.Weak (Weak, deRefWeak) import UnliftIO (timeout) import UnliftIO.Concurrent @@ -113,7 +116,7 @@ runTCPServer started port server = do runTCPServerSocket :: SocketState -> TMVar Bool -> IO Socket -> (Socket -> IO ()) -> IO () runTCPServerSocket (accepted, gracefullyClosed, clients) started getSocket server = E.bracket getSocket (closeServer started clients) $ \sock -> - forever . E.bracketOnError (accept sock) (close . fst) $ \(conn, _peer) -> do + forever . E.bracketOnError (safeAccept sock) (close . fst) $ \(conn, _peer) -> do cId <- atomically $ stateTVar accepted $ \cId -> let cId' = cId + 1 in cId `seq` (cId', cId') let closeConn _ = do atomically $ modifyTVar' clients $ IM.delete cId @@ -122,6 +125,27 @@ runTCPServerSocket (accepted, gracefullyClosed, clients) started getSocket serve tId <- mkWeakThreadId =<< server conn `forkFinally` closeConn atomically $ modifyTVar' clients $ IM.insert cId tId +-- | Recover from errors in `accept` whenever it is safe. +-- Some errors are safe to ignore, while blindly restaring `accept` may trigger a busy loop. +-- +-- man accept says: +-- @ +-- For reliable operation the application should detect the network errors defined for the protocol after accept() and treat them like EAGAIN by retrying. +-- In the case of TCP/IP, these are ENETDOWN, EPROTO, ENOPROTOOPT, EHOSTDOWN, ENONET, EHOSTUNREACH, EOPNOTSUPP, and ENETUNREACH. +-- @ +safeAccept :: Socket -> IO (Socket, SockAddr) +safeAccept sock = + tryIOError (accept sock) >>= \case + Right r -> pure r + Left e + | retryAccept -> logWarn err >> safeAccept sock + | otherwise -> logError err >> E.throwIO e + where + retryAccept = maybe False ((`elem` again) . Errno) errno + again = [eAGAIN, eNETDOWN, ePROTO, eNOPROTOOPT, eHOSTDOWN, eNONET, eHOSTUNREACH, eOPNOTSUPP, eNETUNREACH] + err = "socket accept error: " <> tshow e <> maybe "" ((", errno=" <>) . tshow) errno + errno = ioe_errno e + type SocketState = (TVar Int, TVar Int, TVar (IntMap (Weak ThreadId))) newSocketState :: STM SocketState From 39b3b5a25ece03fa559a9a852543305c9c0767d3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 29 May 2024 13:19:10 +0100 Subject: [PATCH 096/107] 5.8.0.8 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 68b93c1ac..6bdb03e4d 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.7 +version: 5.8.0.8 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 7f53a2ba1..829fd23df 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.7 +version: 5.8.0.8 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 97a953550f90b14dbf0f9a62077e88df306ebeca Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 30 May 2024 14:21:29 +0400 Subject: [PATCH 097/107] agent: getAgentQueuesInfo (#1180) --- src/Simplex/Messaging/Agent/Client.hs | 87 ++++++++++++++++++++------- src/Simplex/Messaging/Client.hs | 25 ++++++++ src/Simplex/Messaging/Client/Agent.hs | 16 ++--- src/Simplex/Messaging/Session.hs | 10 +-- 4 files changed, 106 insertions(+), 32 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index a99d957e3..bc2a41ae2 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -137,6 +137,8 @@ module Simplex.Messaging.Agent.Client getAgentWorkersDetails, AgentWorkersSummary (..), getAgentWorkersSummary, + AgentQueuesInfo (..), + getAgentQueuesInfo, SMPTransportSession, NtfTransportSession, XFTPTransportSession, @@ -204,7 +206,7 @@ import Simplex.Messaging.Notifications.Client import Simplex.Messaging.Notifications.Protocol import Simplex.Messaging.Notifications.Transport (NTFVersion) import Simplex.Messaging.Notifications.Types -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parse) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parse, sumTypeJSON) import Simplex.Messaging.Protocol ( AProtocolType (..), BrokerMsg, @@ -582,7 +584,8 @@ instance ProtocolServerClient XFTPVersion XFTPErrorType FileResponse where getSMPServerClient :: AgentClient -> SMPTransportSession -> AM SMPConnectedClient getSMPServerClient c@AgentClient {active, smpClients, workerSeq} tSess = do unlessM (readTVarIO active) . throwError $ INACTIVE - atomically (getSessVar workerSeq tSess smpClients) + ts <- liftIO getCurrentTime + atomically (getSessVar workerSeq tSess smpClients ts) >>= either newClient (waitForProtocolClient c tSess smpClients) where newClient v = do @@ -593,28 +596,30 @@ getSMPProxyClient :: AgentClient -> SMPTransportSession -> AM (SMPConnectedClien getSMPProxyClient c@AgentClient {active, smpClients, smpProxiedRelays, workerSeq} destSess@(userId, destSrv, qId) = do unlessM (readTVarIO active) . throwError $ INACTIVE proxySrv <- getNextServer c userId [destSrv] - atomically (getClientVar proxySrv) >>= \(tSess, auth, v) -> - either (newProxyClient tSess auth) (waitForProxyClient tSess auth) v + ts <- liftIO getCurrentTime + atomically (getClientVar proxySrv ts) >>= \(tSess, auth, v) -> + either (newProxyClient tSess auth ts) (waitForProxyClient tSess auth) v where - getClientVar :: SMPServerWithAuth -> STM (SMPTransportSession, Maybe SMP.BasicAuth, Either SMPClientVar SMPClientVar) - getClientVar proxySrv = do + getClientVar :: SMPServerWithAuth -> UTCTime -> STM (SMPTransportSession, Maybe SMP.BasicAuth, Either SMPClientVar SMPClientVar) + getClientVar proxySrv ts = do ProtoServerWithAuth srv auth <- TM.lookup destSess smpProxiedRelays >>= maybe (TM.insert destSess proxySrv smpProxiedRelays $> proxySrv) pure let tSess = (userId, srv, qId) - (tSess,auth,) <$> getSessVar workerSeq tSess smpClients - newProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) - newProxyClient tSess auth v = do + (tSess,auth,) <$> getSessVar workerSeq tSess smpClients ts + newProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> UTCTime -> SMPClientVar -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) + newProxyClient tSess auth ts v = do (prs, rv) <- atomically $ do prs <- TM.empty -- we do not need to check if it is a new proxied relay session, -- as the client is just created and there are no sessions yet - (prs,) . either id id <$> getSessVar workerSeq destSrv prs + (prs,) . either id id <$> getSessVar workerSeq destSrv prs ts clnt <- smpConnectClient c tSess prs v (clnt,) <$> newProxiedRelay clnt auth rv waitForProxyClient :: SMPTransportSession -> Maybe SMP.BasicAuth -> SMPClientVar -> AM (SMPConnectedClient, Either AgentErrorType ProxiedRelay) waitForProxyClient tSess auth v = do clnt@(SMPConnectedClient _ prs) <- waitForProtocolClient c tSess smpClients v + ts <- liftIO getCurrentTime sess <- - atomically (getSessVar workerSeq destSrv prs) + atomically (getSessVar workerSeq destSrv prs ts) >>= either (newProxiedRelay clnt auth) (waitForProxiedRelay tSess) pure (clnt, sess) newProxiedRelay :: SMPConnectedClient -> Maybe SMP.BasicAuth -> ProxiedRelayVar -> AM (Either AgentErrorType ProxiedRelay) @@ -688,14 +693,15 @@ smpClientDisconnected c@AgentClient {active, smpClients, smpProxiedRelays} tSess notifySub connId cmd = atomically $ writeTBQueue (subQ c) ("", connId, APC (sAEntity @e) cmd) resubscribeSMPSession :: AgentClient -> SMPTransportSession -> AM' () -resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = - atomically getWorkerVar >>= mapM_ (either newSubWorker (\_ -> pure ())) +resubscribeSMPSession c@AgentClient {smpSubWorkers, workerSeq} tSess = do + ts <- liftIO getCurrentTime + atomically (getWorkerVar ts) >>= mapM_ (either newSubWorker (\_ -> pure ())) where - getWorkerVar = + getWorkerVar ts = ifM (null <$> getPending) (pure Nothing) -- prevent race with cleanup and adding pending queues in another call - (Just <$> getSessVar workerSeq tSess smpSubWorkers) + (Just <$> getSessVar workerSeq tSess smpSubWorkers ts) newSubWorker v = do a <- async $ void (E.tryAny runSubWorker) >> atomically (cleanup v) atomically $ putTMVar (sessionVar v) a @@ -740,7 +746,8 @@ reconnectSMPClient c tSess@(_, srv, _) qs = handleNotify $ do getNtfServerClient :: AgentClient -> NtfTransportSession -> AM NtfClient getNtfServerClient c@AgentClient {active, ntfClients, workerSeq} tSess@(userId, srv, _) = do unlessM (readTVarIO active) . throwError $ INACTIVE - atomically (getSessVar workerSeq tSess ntfClients) + ts <- liftIO getCurrentTime + atomically (getSessVar workerSeq tSess ntfClients ts) >>= either (newProtocolClient c tSess ntfClients connectClient) (waitForProtocolClient c tSess ntfClients) @@ -763,7 +770,8 @@ getNtfServerClient c@AgentClient {active, ntfClients, workerSeq} tSess@(userId, getXFTPServerClient :: AgentClient -> XFTPTransportSession -> AM XFTPClient getXFTPServerClient c@AgentClient {active, xftpClients, workerSeq} tSess@(userId, srv, _) = do unlessM (readTVarIO active) . throwError $ INACTIVE - atomically (getSessVar workerSeq tSess xftpClients) + ts <- liftIO getCurrentTime + atomically (getSessVar workerSeq tSess xftpClients ts) >>= either (newProtocolClient c tSess xftpClients connectClient) (waitForProtocolClient c tSess xftpClients) @@ -1060,10 +1068,11 @@ sendOrProxySMPMessage c userId destSrv cmdStr spKey_ senderId msgFlags msg = do $>>= \(ProtoServerWithAuth srv _) -> tryReadSessVar (userId, srv, qId) (smpClients c) ) >>= \case - Just (Right (SMPConnectedClient smp' prs)) | sameClient smp' -> - tryReadSessVar destSrv prs >>= \case - Just (Right proxySess') | sameProxiedRelay proxySess' -> TM.delete destSrv prs - _ -> pure () + Just (Right (SMPConnectedClient smp' prs)) + | sameClient smp' -> + tryReadSessVar destSrv prs >>= \case + Just (Right proxySess') | sameProxiedRelay proxySess' -> TM.delete destSrv prs + _ -> pure () _ -> pure () sameClient smp' = sessionId (thParams smp) == sessionId (thParams smp') sameProxiedRelay proxySess' = prSessionId proxySess == prSessionId proxySess' @@ -2041,6 +2050,38 @@ getAgentWorkersSummary AgentClient {smpClients, ntfClients, xftpClients, smpDeli (pure WorkersSummary {numActive, numIdle = numIdle + 1, totalRestarts = totalRestarts + restartCount}) (pure WorkersSummary {numActive = numActive + 1, numIdle, totalRestarts = totalRestarts + restartCount}) +data AgentQueuesInfo = AgentQueuesInfo + { msgQInfo :: TBQueueInfo, + subQInfo :: TBQueueInfo, + smpClientsQueues :: Map Text (Int, UTCTime, ClientInfo) + } + deriving (Show) + +data ClientInfo + = ClientInfoQueues {sndQInfo :: TBQueueInfo, rcvQInfo :: TBQueueInfo} + | ClientInfoError {clientError :: (AgentErrorType, Maybe UTCTime)} + | ClientInfoConnecting + deriving (Show) + +getAgentQueuesInfo :: AgentClient -> IO AgentQueuesInfo +getAgentQueuesInfo AgentClient {msgQ, subQ, smpClients} = do + msgQInfo <- atomically $ getTBQueueInfo msgQ + subQInfo <- atomically $ getTBQueueInfo subQ + smpClientsMap <- readTVarIO smpClients + let smpClientsMap' = M.mapKeys (decodeLatin1 . strEncode) smpClientsMap + smpClientsQueues <- mapM getClientQueuesInfo smpClientsMap' + pure AgentQueuesInfo {msgQInfo, subQInfo, smpClientsQueues} + where + getClientQueuesInfo :: SMPClientVar -> IO (Int, UTCTime, ClientInfo) + getClientQueuesInfo SessionVar {sessionVar, sessionVarId, sessionVarTs} = do + clientInfo <- atomically (tryReadTMVar sessionVar) >>= \case + Just (Right c) -> do + (sndQInfo, rcvQInfo) <- getProtocolClientQueuesInfo $ protocolClient c + pure ClientInfoQueues {sndQInfo, rcvQInfo} + Just (Left e) -> pure $ ClientInfoError e + Nothing -> pure ClientInfoConnecting + pure (sessionVarId, sessionVarTs, clientInfo) + $(J.deriveJSON defaultJSON ''AgentLocks) $(J.deriveJSON (enumJSON $ dropPrefix "TS") ''ProtocolTestStep) @@ -2059,6 +2100,10 @@ $(J.deriveJSON defaultJSON {J.fieldLabelModifier = takeWhile (/= '_')} ''AgentWo $(J.deriveJSON defaultJSON ''AgentWorkersSummary) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "ClientInfo") ''ClientInfo) + +$(J.deriveJSON defaultJSON ''AgentQueuesInfo) + $(J.deriveJSON (enumJSON $ dropPrefix "UN") ''UserNetworkType) $(J.deriveJSON defaultJSON ''UserNetworkInfo) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index e33c91873..8c23db822 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -90,6 +90,11 @@ module Simplex.Messaging.Client mkTransmission, authTransmission, smpClientStub, + + -- * For debugging + TBQueueInfo (..), + getTBQueueInfo, + getProtocolClientQueuesInfo, ) where @@ -1054,6 +1059,24 @@ authTransmission thAuth pKey_ nonce t = traverse authenticate pKey_ sign :: forall a. (C.AlgorithmI a, C.SignatureAlgorithm a) => C.PrivateKey a -> Either TransportError TransmissionAuth sign pk = Right $ TASignature $ C.ASignature (C.sAlgorithm @a) (C.sign' pk t) +data TBQueueInfo = TBQueueInfo + { qLength :: Int, + qFull :: Bool + } + deriving (Show) + +getTBQueueInfo :: TBQueue a -> STM TBQueueInfo +getTBQueueInfo q = do + qLength <- fromIntegral <$> lengthTBQueue q + qFull <- isFullTBQueue q + pure TBQueueInfo {qLength, qFull} + +getProtocolClientQueuesInfo :: ProtocolClient v err msg -> IO (TBQueueInfo, TBQueueInfo) +getProtocolClientQueuesInfo ProtocolClient {client_ = PClient {sndQ, rcvQ}} = do + sndQInfo <- atomically $ getTBQueueInfo sndQ + rcvQInfo <- atomically $ getTBQueueInfo rcvQ + pure (sndQInfo, rcvQInfo) + $(J.deriveJSON (enumJSON $ dropPrefix "HM") ''HostMode) $(J.deriveJSON (enumJSON $ dropPrefix "SM") ''SocksMode) @@ -1067,3 +1090,5 @@ $(J.deriveJSON (enumJSON $ dropPrefix "SPF") ''SMPProxyFallback) $(J.deriveJSON defaultJSON ''NetworkConfig) $(J.deriveJSON (enumJSON $ dropPrefix "Proxy") ''ProxyClientError) + +$(J.deriveJSON defaultJSON ''TBQueueInfo) diff --git a/src/Simplex/Messaging/Client/Agent.hs b/src/Simplex/Messaging/Client/Agent.hs index 3b4a78cf5..8781d87a6 100644 --- a/src/Simplex/Messaging/Client/Agent.hs +++ b/src/Simplex/Messaging/Client/Agent.hs @@ -143,10 +143,11 @@ getSMPServerClient' ca srv = snd <$> getSMPServerClient'' ca srv {-# INLINE getSMPServerClient' #-} getSMPServerClient'' :: SMPClientAgent -> SMPServer -> ExceptT SMPClientError IO (OwnServer, SMPClient) -getSMPServerClient'' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, workerSeq} srv = - atomically getClientVar >>= either (ExceptT . newSMPClient) waitForSMPClient +getSMPServerClient'' ca@SMPClientAgent {agentCfg, smpClients, smpSessions, workerSeq} srv = do + ts <- liftIO getCurrentTime + atomically (getClientVar ts) >>= either (ExceptT . newSMPClient) waitForSMPClient where - getClientVar :: STM (Either SMPClientVar SMPClientVar) + getClientVar :: UTCTime -> STM (Either SMPClientVar SMPClientVar) getClientVar = getSessVar workerSeq srv smpClients waitForSMPClient :: SMPClientVar -> ExceptT SMPClientError IO (OwnServer, SMPClient) @@ -227,14 +228,15 @@ connectClient ca@SMPClientAgent {agentCfg, smpClients, smpSessions, msgQ, random -- | Spawn reconnect worker if needed reconnectClient :: SMPClientAgent -> SMPServer -> IO () -reconnectClient ca@SMPClientAgent {active, agentCfg, smpSubWorkers, workerSeq} srv = - whenM (readTVarIO active) $ atomically getWorkerVar >>= mapM_ (either newSubWorker (\_ -> pure ())) +reconnectClient ca@SMPClientAgent {active, agentCfg, smpSubWorkers, workerSeq} srv = do + ts <- getCurrentTime + whenM (readTVarIO active) $ atomically (getWorkerVar ts) >>= mapM_ (either newSubWorker (\_ -> pure ())) where - getWorkerVar = + getWorkerVar ts = ifM (null <$> getPending) (pure Nothing) -- prevent race with cleanup and adding pending queues in another call - (Just <$> getSessVar workerSeq srv smpSubWorkers) + (Just <$> getSessVar workerSeq srv smpSubWorkers ts) newSubWorker :: SessionVar (Async ()) -> IO () newSubWorker v = do a <- async $ void (E.tryAny runSubWorker) >> atomically (cleanup v) diff --git a/src/Simplex/Messaging/Session.hs b/src/Simplex/Messaging/Session.hs index 75543b481..3ce5a35c8 100644 --- a/src/Simplex/Messaging/Session.hs +++ b/src/Simplex/Messaging/Session.hs @@ -8,23 +8,25 @@ import Control.Concurrent.STM import Control.Monad import Data.Composition ((.:.)) import Data.Functor (($>)) +import Data.Time (UTCTime) import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (($>>=)) data SessionVar a = SessionVar { sessionVar :: TMVar a, - sessionVarId :: Int + sessionVarId :: Int, + sessionVarTs :: UTCTime } -getSessVar :: forall k a. Ord k => TVar Int -> k -> TMap k (SessionVar a) -> STM (Either (SessionVar a) (SessionVar a)) -getSessVar sessSeq sessKey vs = maybe (Left <$> newSessionVar) (pure . Right) =<< TM.lookup sessKey vs +getSessVar :: forall k a. Ord k => TVar Int -> k -> TMap k (SessionVar a) -> UTCTime -> STM (Either (SessionVar a) (SessionVar a)) +getSessVar sessSeq sessKey vs sessionVarTs = maybe (Left <$> newSessionVar) (pure . Right) =<< TM.lookup sessKey vs where newSessionVar :: STM (SessionVar a) newSessionVar = do sessionVar <- newEmptyTMVar sessionVarId <- stateTVar sessSeq $ \next -> (next, next + 1) - let v = SessionVar {sessionVar, sessionVarId} + let v = SessionVar {sessionVar, sessionVarId, sessionVarTs} TM.insert sessKey v vs pure v From 88f1b727e0e4bfef01a346d818b7f6d94a181b16 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 30 May 2024 18:49:43 +0100 Subject: [PATCH 098/107] SMP protocol extension to debug subscribed SMP queues (#1181) * SMP protocol extension to debug subscribed SMP queues * fix, test * corrections Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> * increase delays * increase timeout * delay * delay * enable all tests --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- simplexmq.cabal | 1 + src/Simplex/Messaging/Agent.hs | 16 +++ src/Simplex/Messaging/Agent/Client.hs | 7 ++ src/Simplex/Messaging/Client.hs | 8 ++ src/Simplex/Messaging/Protocol.hs | 25 ++++- src/Simplex/Messaging/Server.hs | 24 ++++- .../Messaging/Server/QueueStore/QueueInfo.hs | 55 +++++++++++ src/Simplex/RemoteControl/Invitation.hs | 2 +- tests/AgentTests/FunctionalAPITests.hs | 98 ++++++++++++++++++- tests/CLITests.hs | 4 +- 10 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 src/Simplex/Messaging/Server/QueueStore/QueueInfo.hs diff --git a/simplexmq.cabal b/simplexmq.cabal index 829fd23df..31dfff436 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -172,6 +172,7 @@ library Simplex.Messaging.Server.MsgStore Simplex.Messaging.Server.MsgStore.STM Simplex.Messaging.Server.QueueStore + Simplex.Messaging.Server.QueueStore.QueueInfo Simplex.Messaging.Server.QueueStore.STM Simplex.Messaging.Server.Stats Simplex.Messaging.Server.StoreLog diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 76d7d333d..073660ba0 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -70,6 +70,7 @@ module Simplex.Messaging.Agent sendMessages, sendMessagesB, ackMessage, + getConnectionQueueInfo, switchConnection, abortConnectionSwitch, synchronizeRatchet, @@ -176,6 +177,7 @@ import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (parse) import Simplex.Messaging.Protocol (BrokerMsg, Cmd (..), EntityId, ErrorType (AUTH), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth, ProtocolTypeI (..), SMPMsgMeta, SParty (..), SProtocolType (..), SndPublicAuthKey, SubscriptionMode (..), UserProtocol, VersionSMPC, XFTPServerWithAuth) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (SMPVersion, THandleParams (sessionId)) @@ -371,6 +373,10 @@ ackMessage :: AgentClient -> ConnId -> AgentMsgId -> Maybe MsgReceiptInfo -> AE ackMessage c = withAgentEnv c .:. ackMessage' c {-# INLINE ackMessage #-} +getConnectionQueueInfo :: AgentClient -> ConnId -> AE QueueInfo +getConnectionQueueInfo c = withAgentEnv c . getConnectionQueueInfo' c +{-# INLINE getConnectionQueueInfo #-} + -- | Switch connection to the new receive queue switchConnection :: AgentClient -> ConnId -> AE ConnectionStats switchConnection c = withAgentEnv c . switchConnection' c @@ -1510,6 +1516,16 @@ ackMessage' c connId msgId rcptInfo_ = withConnLock c connId "ackMessage" $ do withStore' c $ \db -> deleteDeliveredSndMsg db connId $ InternalId sndMsgId _ -> pure () +getConnectionQueueInfo' :: AgentClient -> ConnId -> AM QueueInfo +getConnectionQueueInfo' c connId = do + SomeConn _ conn <- withStore c (`getConn` connId) + case conn of + DuplexConnection _ (rq :| _) _ -> getQueueInfo c rq + RcvConnection _ rq -> getQueueInfo c rq + ContactConnection _ rq -> getQueueInfo c rq + SndConnection {} -> throwE $ CONN SIMPLEX + NewConnection _ -> throwE $ CMD PROHIBITED "getConnectionQueueInfo': NewConnection" + switchConnection' :: AgentClient -> ConnId -> AM ConnectionStats switchConnection' c connId = withConnLock c connId "switchConnection" $ diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index bc2a41ae2..e63ab5d1d 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -56,6 +56,7 @@ module Simplex.Messaging.Agent.Client disableQueueNotifications, disableQueuesNtfs, sendAgentMessage, + getQueueInfo, agentNtfRegisterToken, agentNtfVerifyToken, agentNtfCheckToken, @@ -238,6 +239,7 @@ import Simplex.Messaging.Protocol sameSrvAddr', ) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.Session import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM @@ -1577,6 +1579,11 @@ sendAgentMessage c sq@SndQueue {userId, server, sndId, sndPrivateKey} msgFlags a msg <- agentCbEncrypt sq Nothing $ smpEncode clientMsg sendOrProxySMPMessage c userId server "" (Just sndPrivateKey) sndId msgFlags msg +getQueueInfo :: AgentClient -> RcvQueue -> AM QueueInfo +getQueueInfo c rq@RcvQueue {rcvId, rcvPrivateKey} = + withSMPClient c rq "QUE" $ \smp -> + getSMPQueueInfo smp rcvPrivateKey rcvId + agentNtfRegisterToken :: AgentClient -> NtfToken -> NtfPublicAuthKey -> C.PublicKeyX25519 -> AM (NtfTokenId, C.PublicKeyX25519) agentNtfRegisterToken c NtfToken {deviceToken, ntfServer, ntfPrivKey} ntfPubKey pubDhKey = withClient c (0, ntfServer, Nothing) "TNEW" $ \ntf -> ntfRegisterToken ntf ntfPrivKey (NewNtfTkn deviceToken ntfPubKey pubDhKey) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 8c23db822..79838792e 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -59,6 +59,7 @@ module Simplex.Messaging.Client connectSMPProxiedRelay, proxySMPMessage, forwardSMPMessage, + getSMPQueueInfo, sendProtocolCommand, -- * Supporting types and client configuration @@ -128,6 +129,7 @@ import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) import Simplex.Messaging.Protocol +import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport @@ -920,6 +922,12 @@ forwardSMPMessage c@ProtocolClient {thParams, client_ = PClient {clientCorrId = pure fwdResponse r -> throwE $ unexpectedResponse r +getSMPQueueInfo :: SMPClient -> C.APrivateAuthKey -> QueueId -> ExceptT SMPClientError IO QueueInfo +getSMPQueueInfo c pKey qId = + sendSMPCommand c (Just pKey) qId QUE >>= \case + INFO info -> pure info + r -> throwE $ unexpectedResponse r + okSMPCommand :: PartyI p => Command p -> SMPClient -> C.APrivateAuthKey -> QueueId -> ExceptT SMPClientError IO () okSMPCommand cmd c pKey qId = sendSMPCommand c (Just pKey) qId cmd >>= \case diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 1812dce37..5edea1719 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -156,6 +156,7 @@ module Simplex.Messaging.Protocol sameSrvAddr, sameSrvAddr', noAuthSrv, + toMsgInfo, -- * TCP transport functions TransportBatch (..), @@ -197,8 +198,8 @@ import qualified Data.List.NonEmpty as L import Data.Maybe (isJust, isNothing) import Data.String import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Data.Time.Clock.System (SystemTime (..)) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Type.Equality import Data.Word (Word16) import qualified Data.X509 as X @@ -210,6 +211,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers +import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) @@ -386,6 +388,7 @@ data Command (p :: Party) where ACK :: MsgId -> Command Recipient OFF :: Command Recipient DEL :: Command Recipient + QUE :: Command Recipient -- SMP sender commands -- SEND v1 has to be supported for encoding/decoding -- SEND :: MsgBody -> Command Sender @@ -463,6 +466,7 @@ data BrokerMsg where RRES :: EncFwdResponse -> BrokerMsg -- relay to proxy PRES :: EncResponse -> BrokerMsg -- proxy to client END :: BrokerMsg + INFO :: QueueInfo -> BrokerMsg OK :: BrokerMsg ERR :: ErrorType -> BrokerMsg PONG :: BrokerMsg @@ -505,6 +509,13 @@ data Message msgTs :: SystemTime } +toMsgInfo :: Message -> MsgInfo +toMsgInfo = \case + Message {msgId, msgTs} -> msgInfo msgId msgTs MTMessage + MessageQuota {msgId, msgTs} -> msgInfo msgId msgTs MTQuota + where + msgInfo msgId msgTs msgType = MsgInfo {msgId = decodeLatin1 $ B64.encode msgId, msgTs = systemToUTCTime msgTs, msgType} + messageId :: Message -> MsgId messageId = \case Message {msgId} -> msgId @@ -652,6 +663,7 @@ data CommandTag (p :: Party) where ACK_ :: CommandTag Recipient OFF_ :: CommandTag Recipient DEL_ :: CommandTag Recipient + QUE_ :: CommandTag Recipient SEND_ :: CommandTag Sender PING_ :: CommandTag Sender PRXY_ :: CommandTag ProxiedClient @@ -674,6 +686,7 @@ data BrokerMsgTag | RRES_ | PRES_ | END_ + | INFO_ | OK_ | ERR_ | PONG_ @@ -698,6 +711,7 @@ instance PartyI p => Encoding (CommandTag p) where ACK_ -> "ACK" OFF_ -> "OFF" DEL_ -> "DEL" + QUE_ -> "QUE" SEND_ -> "SEND" PING_ -> "PING" PRXY_ -> "PRXY" @@ -717,6 +731,7 @@ instance ProtocolMsgTag CmdTag where "ACK" -> Just $ CT SRecipient ACK_ "OFF" -> Just $ CT SRecipient OFF_ "DEL" -> Just $ CT SRecipient DEL_ + "QUE" -> Just $ CT SRecipient QUE_ "SEND" -> Just $ CT SSender SEND_ "PING" -> Just $ CT SSender PING_ "PRXY" -> Just $ CT SProxiedClient PRXY_ @@ -742,6 +757,7 @@ instance Encoding BrokerMsgTag where RRES_ -> "RRES" PRES_ -> "PRES" END_ -> "END" + INFO_ -> "INFO" OK_ -> "OK" ERR_ -> "ERR" PONG_ -> "PONG" @@ -757,6 +773,7 @@ instance ProtocolMsgTag BrokerMsgTag where "RRES" -> Just RRES_ "PRES" -> Just PRES_ "END" -> Just END_ + "INFO" -> Just INFO_ "OK" -> Just OK_ "ERR" -> Just ERR_ "PONG" -> Just PONG_ @@ -1275,6 +1292,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where ACK msgId -> e (ACK_, ' ', msgId) OFF -> e OFF_ DEL -> e DEL_ + QUE -> e QUE_ SEND flags msg -> e (SEND_, ' ', flags, ' ', Tail msg) PING -> e PING_ NSUB -> e NSUB_ @@ -1340,6 +1358,7 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where ACK_ -> ACK <$> _smpP OFF_ -> pure OFF DEL_ -> pure DEL + QUE_ -> pure QUE CT SSender tag -> Cmd SSender <$> case tag of SEND_ -> SEND <$> _smpP <*> (unTail <$> _smpP) @@ -1368,6 +1387,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where RRES (EncFwdResponse encBlock) -> e (RRES_, ' ', Tail encBlock) PRES (EncResponse encBlock) -> e (PRES_, ' ', Tail encBlock) END -> e END_ + INFO info -> e (INFO_, ' ', info) OK -> e OK_ ERR err -> e (ERR_, ' ', err) PONG -> e PONG_ @@ -1388,6 +1408,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where RRES_ -> RRES <$> (EncFwdResponse . unTail <$> _smpP) PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP) END_ -> pure END + INFO_ -> INFO <$> _smpP OK_ -> pure OK ERR_ -> ERR <$> _smpP PONG_ -> pure PONG diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 0d209229c..d34c0002c 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -59,7 +59,7 @@ import Data.List (intercalate, mapAccumR) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M -import Data.Maybe (catMaybes, fromMaybe, isNothing) +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Data.Time.Clock (UTCTime (..), diffTimeToPicoseconds, getCurrentTime) @@ -82,6 +82,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.QueueStore +import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.Server.QueueStore.STM as QS import Simplex.Messaging.Server.Stats import Simplex.Messaging.Server.StoreLog @@ -791,6 +792,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi NDEL -> deleteQueueNotifier_ st OFF -> suspendQueue_ st DEL -> delQueueAndMsgs st + QUE -> withQueue getQueueInfo where createQueue :: QueueStore -> RcvPublicAuthKey -> RcvPublicDhKey -> SubscriptionMode -> M (Transmission BrokerMsg) createQueue st recipientKey dhKey subMode = time "NEW" $ do @@ -1162,6 +1164,26 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi Right q -> updateDeletedStats q $> ok Left e -> pure $ err e + getQueueInfo :: QueueRec -> M (Transmission BrokerMsg) + getQueueInfo QueueRec {senderKey, notifier} = do + q@MsgQueue {size} <- getStoreMsgQueue "getQueueInfo" queueId + info <- atomically $ do + qiSub <- TM.lookup queueId subscriptions >>= mapM mkQSub + qiSize <- readTVar size + qiMsg <- toMsgInfo <$$> tryPeekMsg q + pure QueueInfo {qiSnd = isJust senderKey, qiNtf = isJust notifier, qiSub, qiSize, qiMsg} + pure (corrId, queueId, INFO info) + where + mkQSub sub = do + Sub {subThread, delivered} <- readTVar sub + let qSubThread = case subThread of + NoSub -> QNoSub + SubPending -> QSubPending + SubThread _ -> QSubThread + ProhibitSub -> QProhibitSub + qDelivered <- decodeLatin1 . encode <$$> tryReadTMVar delivered + pure QSub {qSubThread, qDelivered} + ok :: Transmission BrokerMsg ok = (corrId, queueId, OK) diff --git a/src/Simplex/Messaging/Server/QueueStore/QueueInfo.hs b/src/Simplex/Messaging/Server/QueueStore/QueueInfo.hs new file mode 100644 index 000000000..b329a54ff --- /dev/null +++ b/src/Simplex/Messaging/Server/QueueStore/QueueInfo.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Messaging.Server.QueueStore.QueueInfo where + +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Messaging.Encoding +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +import Simplex.Messaging.Util ((<$?>)) + +data QueueInfo = QueueInfo + { qiSnd :: Bool, + qiNtf :: Bool, + qiSub :: Maybe QSub, + qiSize :: Int, + qiMsg :: Maybe MsgInfo + } + deriving (Eq, Show) + +data QSub = QSub + { qSubThread :: QSubThread, + qDelivered :: Maybe Text + } + deriving (Eq, Show) + +data QSubThread = QNoSub | QSubPending | QSubThread | QProhibitSub + deriving (Eq, Show) + +data MsgInfo = MsgInfo + { msgId :: Text, + msgTs :: UTCTime, + msgType :: MsgType + } + deriving (Eq, Show) + +data MsgType = MTMessage | MTQuota + deriving (Eq, Show) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "Q") ''QSubThread) + +$(JQ.deriveJSON defaultJSON ''QSub) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "MT") ''MsgType) + +$(JQ.deriveJSON defaultJSON ''MsgInfo) + +$(JQ.deriveJSON defaultJSON ''QueueInfo) + +instance Encoding QueueInfo where + smpEncode = LB.toStrict . J.encode + smpP = J.eitherDecodeStrict <$?> A.takeByteString diff --git a/src/Simplex/RemoteControl/Invitation.hs b/src/Simplex/RemoteControl/Invitation.hs index 712c41a9d..d606c4ff0 100644 --- a/src/Simplex/RemoteControl/Invitation.hs +++ b/src/Simplex/RemoteControl/Invitation.hs @@ -123,7 +123,7 @@ instance StrEncoding RCSignedInvitation where idsig <- requiredP sigs "idsig" $ parseAll strP pure RCSignedInvitation {invitation, ssig, idsig} -signInvitation :: C.PrivateKey C.Ed25519 -> C.PrivateKey C.Ed25519 -> RCInvitation -> RCSignedInvitation +signInvitation :: C.PrivateKey 'C.Ed25519 -> C.PrivateKey 'C.Ed25519 -> RCInvitation -> RCSignedInvitation signInvitation sKey idKey invitation = RCSignedInvitation {invitation, ssig, idsig} where uri = strEncode invitation diff --git a/tests/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 2b21ff3f7..c2badea63 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -57,6 +57,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Bifunctor (first) +import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isRight) @@ -66,6 +67,7 @@ import Data.List.NonEmpty (NonEmpty) import qualified Data.Map as M import Data.Maybe (isJust, isNothing) import qualified Data.Set as S +import Data.Text.Encoding (decodeLatin1) import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.Clock.System (SystemTime (..), getSystemTime) import Data.Type.Equality (testEquality, (:~:) (Refl)) @@ -92,6 +94,7 @@ import Simplex.Messaging.Protocol (BasicAuth, ErrorType (..), MsgBody, ProtocolS import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Server.Env.STM (ServerConfig (..)) import Simplex.Messaging.Server.Expiration +import Simplex.Messaging.Server.QueueStore.QueueInfo import Simplex.Messaging.Transport (ATransport (..), SMPVersion, VersionSMP, authCmdsSMPVersion, basicAuthSMPVersion, batchCmdsSMPVersion, currentServerSMPRelayVersion, supportedSMPHandshakes) import Simplex.Messaging.Util (diffToMicroseconds) import Simplex.Messaging.Version (VersionRange (..)) @@ -107,8 +110,11 @@ type AEntityTransmission e = (ACorrId, ConnId, ACommand 'Agent e) -- deriving instance Eq (ValidFileDescription p) +shouldRespond :: (HasCallStack, MonadUnliftIO m, Eq a, Show a) => m a -> a -> m () +a `shouldRespond` r = withFrozenCallStack $ withTimeout a (`shouldBe` r) + (##>) :: (HasCallStack, MonadUnliftIO m) => m (AEntityTransmission e) -> AEntityTransmission e -> m () -a ##> t = withTimeout a (`shouldBe` t) +a ##> t = a `shouldRespond` t (=##>) :: (Show a, HasCallStack, MonadUnliftIO m) => m a -> (HasCallStack => a -> Bool) -> m () a =##> p = @@ -228,7 +234,7 @@ mkVersionRange :: Word16 -> Word16 -> VersionRange v mkVersionRange v1 v2 = V.mkVersionRange (Version v1) (Version v2) runRight_ :: (Eq e, Show e, HasCallStack) => ExceptT e IO () -> Expectation -runRight_ action = runExceptT action `shouldReturn` Right () +runRight_ action = withFrozenCallStack $ runExceptT action `shouldReturn` Right () runRight :: (Show e, HasCallStack) => ExceptT e IO a -> IO a runRight action = @@ -444,6 +450,9 @@ functionalAPITests t = do it "should wait for user network" testWaitForUserNetwork it "should not reset online to offline if happens too quickly" testDoNotResetOnlineToOffline it "should resume multiple threads" testResumeMultipleThreads + describe "SMP queue info" $ do + it "server should respond with queue and subscription information" $ + withSmpServer t testServerQueueInfo testBasicAuth :: ATransport -> Bool -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> (Maybe BasicAuth, VersionSMP) -> IO Int testBasicAuth t allowNewQueues srv@(srvAuth, srvVersion) clnt1 clnt2 = do @@ -2755,6 +2764,91 @@ testResumeMultipleThreads = do where aCfg = agentCfg {userOfflineDelay = 0} +testServerQueueInfo :: IO () +testServerQueueInfo = do + withAgentClients2 $ \alice bob -> runRight_ $ do + (bobId, cReq) <- createConnection alice 1 True SCMInvitation Nothing SMSubscribe + liftIO $ threadDelay 200000 + checkEmptyQ alice bobId False + aliceId <- joinConnection bob 1 True cReq "bob's connInfo" SMSubscribe + ("", _, CONF confId _ "bob's connInfo") <- get alice + liftIO $ threadDelay 200000 + checkEmptyQ alice bobId False + allowConnection alice bobId confId "alice's connInfo" + get alice ##> ("", bobId, CON) + get bob ##> ("", aliceId, INFO "alice's connInfo") + get bob ##> ("", aliceId, CON) + liftIO $ threadDelay 200000 + checkEmptyQ alice bobId True + checkEmptyQ bob aliceId True + let msgId = 4 + (msgId', PQEncOn) <- A.sendMessage alice bobId PQEncOn SMP.noMsgFlags "hello" + liftIO $ msgId' `shouldBe` msgId + get alice ##> ("", bobId, SENT msgId) + liftIO $ threadDelay 200000 + Just srvMsgId <- checkMsgQ bob aliceId 1 + get bob =##> \case + ("", c, MSG MsgMeta {integrity = MsgOk, broker = (smId, _), recipient = (mId, _), pqEncryption = PQEncOn} _ "hello") -> + c == aliceId && decodeLatin1 (B64.encode smId) == srvMsgId && mId == msgId + _ -> False + ackMessage bob aliceId msgId Nothing + liftIO $ threadDelay 200000 + checkEmptyQ bob aliceId True + (msgId1, PQEncOn) <- A.sendMessage alice bobId PQEncOn SMP.noMsgFlags "hello 1" + get alice ##> ("", bobId, SENT msgId1) + Just _ <- checkMsgQ bob aliceId 1 + (msgId2, PQEncOn) <- A.sendMessage alice bobId PQEncOn SMP.noMsgFlags "hello 2" + get alice ##> ("", bobId, SENT msgId2) + (msgId3, PQEncOn) <- A.sendMessage alice bobId PQEncOn SMP.noMsgFlags "hello 3" + get alice ##> ("", bobId, SENT msgId3) + (msgId4, PQEncOn) <- A.sendMessage alice bobId PQEncOn SMP.noMsgFlags "hello 4" + get alice ##> ("", bobId, SENT msgId4) + Just _ <- checkMsgQ bob aliceId 4 + (msgId5, PQEncOn) <- A.sendMessage alice bobId PQEncOn SMP.noMsgFlags "hello: quota exceeded" + liftIO $ threadDelay 200000 + Just _ <- checkMsgQ bob aliceId 5 + get bob =##> \case ("", c, Msg' mId PQEncOn "hello 1") -> c == aliceId && mId == msgId1; _ -> False + ackMessage bob aliceId msgId1 Nothing + liftIO $ threadDelay 200000 + Just _ <- checkMsgQ bob aliceId 4 + get bob =##> \case ("", c, Msg' mId PQEncOn "hello 2") -> c == aliceId && mId == msgId2; _ -> False + ackMessage bob aliceId msgId2 Nothing + get bob =##> \case ("", c, Msg' mId PQEncOn "hello 3") -> c == aliceId && mId == msgId3; _ -> False + ackMessage bob aliceId msgId3 Nothing + liftIO $ threadDelay 200000 + Just _ <- checkMsgQ bob aliceId 2 + get bob =##> \case ("", c, Msg' mId PQEncOn "hello 4") -> c == aliceId && mId == msgId4; _ -> False + ackMessage bob aliceId msgId4 Nothing + liftIO $ threadDelay 200000 + Just _ <- checkMsgQ bob aliceId 1 -- the one that did not fit now accepted + get alice ##> ("", bobId, QCONT) + get alice ##> ("", bobId, SENT msgId5) + liftIO $ threadDelay 200000 + Just _srvMsgId <- checkQ bob aliceId True (Just QNoSub) 1 (Just MTMessage) + get bob =##> \case ("", c, Msg' mId PQEncOn "hello: quota exceeded") -> c == aliceId && mId == msgId5 + 1; _ -> False + ackMessage bob aliceId (msgId5 + 1) Nothing + liftIO $ threadDelay 200000 + checkEmptyQ bob aliceId True + pure () + where + checkEmptyQ c cId qiSnd' = do + r <- checkQ c cId qiSnd' (Just QSubThread) 0 Nothing + liftIO $ r `shouldBe` Nothing + checkMsgQ c cId qiSize' = do + r <- checkQ c cId True (Just QNoSub) qiSize' (Just MTMessage) + liftIO $ isJust r `shouldBe` True + pure r + checkQ c cId qiSnd' qiSubThread_ qiSize' msgType_ = do + QueueInfo {qiSnd, qiNtf, qiSub, qiSize, qiMsg} <- getConnectionQueueInfo c cId + liftIO $ do + qiSnd `shouldBe` qiSnd' + qiNtf `shouldBe` False + qSubThread <$> qiSub `shouldBe` qiSubThread_ + qiSize `shouldBe` qiSize' + msgId_ <- forM qiMsg $ \MsgInfo {msgId, msgType} -> msgId <$ (Just msgType `shouldBe` msgType_) + qDelivered <$> qiSub `shouldBe` Just msgId_ + pure msgId_ + noNetworkDelay :: AgentClient -> IO () noNetworkDelay a = do d <- waitNetwork a diff --git a/tests/CLITests.hs b/tests/CLITests.hs index 2193de5e9..1310665ee 100644 --- a/tests/CLITests.hs +++ b/tests/CLITests.hs @@ -77,13 +77,13 @@ smpServerTest storeLog basicAuth = do let certPath = cfgPath "server.crt" oldCrt@X.Certificate {} <- XF.readSignedObject certPath >>= \case - [cert] -> pure . X.signedObject $ X.getSigned cert + [cert'] -> pure . X.signedObject $ X.getSigned cert' _ -> error "bad crt format" r' <- lines <$> capture_ (withArgs ["cert"] $ (100000 `timeout` smpServerCLI cfgPath logPath) `catchAll_` pure (Just ())) r' `shouldContain` ["Generated new server credentials"] newCrt <- XF.readSignedObject certPath >>= \case - [cert] -> pure . X.signedObject $ X.getSigned cert + [cert'] -> pure . X.signedObject $ X.getSigned cert' _ -> error "bad crt format after cert" X.certSignatureAlg oldCrt `shouldBe` X.certSignatureAlg newCrt X.certSubjectDN oldCrt `shouldBe` X.certSubjectDN newCrt From 6e4067dc0cfb726a86b83e2641531a2ef8da71eb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 09:16:00 +0100 Subject: [PATCH 099/107] add string encodings for SMPProxyMode and SMPProxyFallback --- src/Simplex/Messaging/Client.hs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index 79838792e..56ebf7a3f 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -291,6 +291,32 @@ data SMPProxyFallback | SPFProhibit -- prohibit direct connection to destination relay. deriving (Eq, Show) +instance StrEncoding SMPProxyMode where + strEncode = \case + SPMAlways -> "always" + SPMUnknown -> "unknown" + SPMUnprotected -> "unprotected" + SPMNever -> "never" + strP = + A.takeTill (== ' ') >>= \case + "always" -> pure SPMAlways + "unknown" -> pure SPMUnknown + "unprotected" -> pure SPMUnprotected + "never" -> pure SPMNever + _ -> fail "Invalid SMP proxy mode" + +instance StrEncoding SMPProxyFallback where + strEncode = \case + SPFAllow -> "yes" + SPFAllowProtected -> "protected" + SPFProhibit -> "no" + strP = + A.takeTill (== ' ') >>= \case + "yes" -> pure SPFAllow + "protected" -> pure SPFAllowProtected + "no" -> pure SPFProhibit + _ -> fail "Invalid SMP proxy fallback mode" + defaultNetworkConfig :: NetworkConfig defaultNetworkConfig = NetworkConfig From d12ea9205537a570f369fc493ce3e7891511f623 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 09:47:47 +0100 Subject: [PATCH 100/107] agent: report correct errors from xftp handshake so they are treated as temporary (#1184) * agent: report correct errors from xftp handshake so they are treated as temporary * disable slow servers test * remove comments * all tests * remove duplicate functions --- package.yaml | 3 +++ src/Simplex/FileTransfer/Client.hs | 27 +++++++++++++++---------- src/Simplex/FileTransfer/Server.hs | 22 ++++++++++++++++++++ src/Simplex/FileTransfer/Server/Env.hs | 3 ++- src/Simplex/FileTransfer/Server/Main.hs | 3 ++- src/Simplex/Messaging/Agent.hs | 7 ------- tests/XFTPAgent.hs | 8 ++++++-- tests/XFTPClient.hs | 3 ++- 8 files changed, 53 insertions(+), 23 deletions(-) diff --git a/package.yaml b/package.yaml index 6bdb03e4d..7b9486ff4 100644 --- a/package.yaml +++ b/package.yaml @@ -87,6 +87,9 @@ flags: manual: True default: True +# cpp-options: +# - -Dslow_servers + when: - condition: flag(swift) cpp-options: diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index ff7742e67..2454a1d57 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -52,7 +52,7 @@ import Simplex.Messaging.Protocol RecipientId, SenderId, ) -import Simplex.Messaging.Transport (ALPN, THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) +import Simplex.Messaging.Transport (ALPN, HandshakeError (..), THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, alpn) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client @@ -98,6 +98,12 @@ defaultXFTPClientConfig = clientALPN = Just supportedXFTPhandshakes } +http2XFTPClientError :: HTTP2ClientError -> XFTPClientError +http2XFTPClientError = \case + HCResponseTimeout -> PCEResponseTimeout + HCNetworkError -> PCENetworkError + HCIOError e -> PCEIOError e + getXFTPClient :: TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do let username = proxyUsername transportSession @@ -116,8 +122,7 @@ getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, logDebug $ "Client negotiated handshake protocol: " <> tshow sessionALPN thParams@THandleParams {thVersion} <- case sessionALPN of Just "xftp/1" -> xftpClientHandshakeV1 serverVRange keyHash http2Client thParams0 - Nothing -> pure thParams0 - _ -> throwError $ PCETransportError TEVersion + _ -> pure thParams0 logDebug $ "Client negotiated protocol: " <> tshow thVersion let c = XFTPClient {http2Client, thParams, transportSession, config} atomically $ writeTVar clientVar $ Just c @@ -135,15 +140,15 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session getServerHandshake = do let helloReq = H.requestNoBody "POST" "/" [] HTTP2Response {respBody = HTTP2Body {bodyHead = shsBody}} <- - liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c helloReq Nothing - liftHS . smpDecode =<< liftHS (C.unPad shsBody) + liftError' http2XFTPClientError $ sendRequest c helloReq Nothing + liftTransportErr (TEHandshake PARSE) . smpDecode =<< liftTransportErr TEBadBlock (C.unPad shsBody) processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionRangeXFTP, C.PublicKeyX25519) processServerHandshake XFTPServerHandshake {xftpVersionRange, sessionId = serverSessId, authPubKey = serverAuth} = do - unless (sessionId == serverSessId) $ throwError $ PCEResponseError SESSION + unless (sessionId == serverSessId) $ throwError $ PCETransportError TEBadSession case xftpVersionRange `compatibleVRange` serverVRange of Nothing -> throwError $ PCETransportError TEVersion Just (Compatible vr) -> - fmap (vr,) . liftHS $ do + fmap (vr,) . liftTransportErr (TEHandshake BAD_AUTH) $ do let (X.CertificateChain cert, exact) = serverAuth case cert of [_leaf, ca] | XV.Fingerprint kh == XV.getFingerprint ca X.HashSHA256 -> pure () @@ -152,11 +157,11 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session C.x509ToPublic (pubKey, []) >>= C.pubKey sendClientHandshake :: XFTPClientHandshake -> ExceptT XFTPClientError IO () sendClientHandshake chs = do - chs' <- liftHS $ C.pad (smpEncode chs) xftpBlockSize + chs' <- liftTransportErr TELargeMsg $ C.pad (smpEncode chs) xftpBlockSize let chsReq = H.requestBuilder "POST" "/" [] $ byteString chs' - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c chsReq Nothing - unless (B.null bodyHead) $ throwError $ PCEResponseError HANDSHAKE - liftHS = liftEitherWith (const $ PCEResponseError HANDSHAKE) + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftError' http2XFTPClientError $ sendRequest c chsReq Nothing + unless (B.null bodyHead) $ throwError $ PCETransportError TEBadBlock + liftTransportErr e = liftEitherWith (const $ PCETransportError e) closeXFTPClient :: XFTPClient -> IO () closeXFTPClient XFTPClient {http2Client} = closeHTTP2Client http2Client diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index ea18b4fdc..7eb65ced2 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -67,6 +68,9 @@ import Simplex.Messaging.Version import System.Exit (exitFailure) import System.FilePath (()) import System.IO (hPrint, hPutStrLn, universalNewlineMode) +#ifdef slow_servers +import System.Random (getStdRandom, randomR) +#endif import UnliftIO import UnliftIO.Concurrent (threadDelay) import UnliftIO.Directory (doesFileExist, removeFile, renameFile) @@ -138,6 +142,9 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira let authPubKey = (chain, C.signX509 serverSignKey $ C.publicToX509 k) let hs = XFTPServerHandshake {xftpVersionRange = xftpServerVRange, sessionId, authPubKey} shs <- encodeXftp hs +#ifdef slow_servers + lift randomDelay +#endif liftIO . sendResponse $ H.responseBuilder N.ok200 [] shs pure Nothing processClientHandshake pk = do @@ -151,6 +158,9 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira let auth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} thParams = thParams0 {thAuth = Just auth, thVersion = v, thServerVRange = vr} atomically $ TM.insert sessionId (HandshakeAccepted thParams) sessions +#ifdef slow_servers + lift randomDelay +#endif liftIO . sendResponse $ H.responseNoBody N.ok200 [] pure Nothing Nothing -> throwError HANDSHAKE @@ -315,6 +325,9 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea where sendXFTPResponse (corrId, fId, resp) serverFile_ = do let t_ = xftpEncodeTransmission thParams (corrId, fId, resp) +#ifdef slow_servers + randomDelay +#endif liftIO $ sendResponse $ H.responseStreaming N.ok200 [] $ streamBody t_ where streamBody t_ send done = do @@ -329,6 +342,15 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea withFile filePath ReadMode $ \h -> sendEncFile h send sbState fileSize done +#ifdef slow_servers +randomDelay :: M () +randomDelay = do + d <- asks $ responseDelay . config + when (d > 0) $ do + pc <- getStdRandom (randomR (-200, 200)) + threadDelay $ (d * (1000 + pc)) `div` 1000 +#endif + data VerificationResult = VRVerified XFTPRequest | VRFailed verifyXFTPTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> XFTPFileId -> FileCmd -> M VerificationResult diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index 58c1393f3..f8a6bc996 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -69,7 +69,8 @@ data XFTPServerConfig = XFTPServerConfig logStatsStartTime :: Int64, serverStatsLogFile :: FilePath, serverStatsBackupFile :: Maybe FilePath, - transportConfig :: TransportServerConfig + transportConfig :: TransportServerConfig, + responseDelay :: Int } defaultInactiveClientExpiration :: ExpirationConfig diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index ee9a52d34..d31295cd2 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -185,7 +185,8 @@ xftpServerCLI cfgPath logPath = do defaultTransportServerConfig { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini, alpn = Just supportedXFTPhandshakes - } + }, + responseDelay = 0 } data CliCommand diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 073660ba0..8e9020b7d 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -83,8 +83,6 @@ module Simplex.Messaging.Agent testProtocolServer, setNtfServers, setNetworkConfig, - getNetworkConfig, - getNetworkConfig', setUserNetworkInfo, reconnectAllServers, registerNtfToken, @@ -434,11 +432,6 @@ setNetworkConfig c@AgentClient {useNetworkConfig} cfg' = do else True <$ (writeTVar useNetworkConfig $! (slowNetworkConfig cfg', cfg')) when changed $ reconnectAllServers c --- returns fast network config -getNetworkConfig :: AgentClient -> IO NetworkConfig -getNetworkConfig = getNetworkConfig' -{-# INLINE getNetworkConfig #-} - setUserNetworkInfo :: AgentClient -> UserNetworkInfo -> IO () setUserNetworkInfo c@AgentClient {userNetworkInfo, userNetworkUpdated} ni = withAgentEnv' c $ do ts' <- liftIO getCurrentTime diff --git a/tests/XFTPAgent.hs b/tests/XFTPAgent.hs index 4685d4815..e4cf3d704 100644 --- a/tests/XFTPAgent.hs +++ b/tests/XFTPAgent.hs @@ -46,7 +46,11 @@ import XFTPClient xftpAgentTests :: Spec xftpAgentTests = around_ testBracket . describe "agent XFTP API" $ do - it "should send and receive file" testXFTPAgentSendReceive + it "should send and receive file" $ withXFTPServer testXFTPAgentSendReceive + -- uncomment CPP option slow_servers and run hpack to run this test + xit "should send and receive file with slow server responses" $ + withXFTPServerCfg testXFTPServerConfig {responseDelay = 500000} $ + \_ -> testXFTPAgentSendReceive it "should send and receive with encrypted local files" testXFTPAgentSendReceiveEncrypted it "should send and receive large file with a redirect" testXFTPAgentSendReceiveRedirect it "should send and receive small file without a redirect" testXFTPAgentSendReceiveNoRedirect @@ -100,7 +104,7 @@ checkProgress (prev, expected) (progress, total) loop | otherwise = pure () testXFTPAgentSendReceive :: HasCallStack => IO () -testXFTPAgentSendReceive = withXFTPServer $ do +testXFTPAgentSendReceive = do filePath <- createRandomFile -- send file, delete snd file internally (rfd1, rfd2) <- withAgent 1 agentCfg initAgentServers testDB $ \sndr -> runRight $ do diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index 0152625a8..208b54dc5 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -124,7 +124,8 @@ testXFTPServerConfig_ alpn = logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", serverStatsBackupFile = Nothing, - transportConfig = defaultTransportServerConfig {alpn} + transportConfig = defaultTransportServerConfig {alpn}, + responseDelay = 0 } testXFTPClientConfig :: XFTPClientConfig From 3a3a84c58c53313906339973adb3caa3fb602b10 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 12:20:29 +0100 Subject: [PATCH 101/107] server: log proxy connection errors --- src/Simplex/Messaging/Server.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index d34c0002c..88e3bf4a3 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -714,6 +714,7 @@ client thParams' clnt@Client {subscriptions, ntfSubscriptions, rcvQ, sndQ, sessi let own = isOwnServer a srv inc own pRequests inc own $ if temporaryClientError e then pErrorsConnect else pErrorsOther + logError $ "Error connecting: " <> decodeLatin1 (strEncode $ host srv) <> " " <> tshow e pure . ERR $ smpProxyError e where proxyResp smp = From 8ed54b33e0f3396721352cfa6546edcc2aedb824 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 09:47:47 +0100 Subject: [PATCH 102/107] agent: report correct errors from xftp handshake so they are treated as temporary (#1184) * agent: report correct errors from xftp handshake so they are treated as temporary * disable slow servers test * remove comments * all tests * remove duplicate functions --- package.yaml | 3 +++ src/Simplex/FileTransfer/Client.hs | 29 +++++++++++++++---------- src/Simplex/FileTransfer/Server.hs | 22 +++++++++++++++++++ src/Simplex/FileTransfer/Server/Env.hs | 3 ++- src/Simplex/FileTransfer/Server/Main.hs | 3 ++- tests/XFTPAgent.hs | 8 +++++-- tests/XFTPClient.hs | 3 ++- 7 files changed, 54 insertions(+), 17 deletions(-) diff --git a/package.yaml b/package.yaml index 49dc27c0c..0eaa36d79 100644 --- a/package.yaml +++ b/package.yaml @@ -85,6 +85,9 @@ flags: manual: True default: True +# cpp-options: +# - -Dslow_servers + when: - condition: flag(swift) cpp-options: diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index e407279ce..49b278e14 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -50,7 +50,7 @@ import Simplex.Messaging.Protocol RecipientId, SenderId, ) -import Simplex.Messaging.Transport (ALPN, HandshakeError (VERSION), THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) +import Simplex.Messaging.Transport (ALPN, HandshakeError (..), THandleAuth (..), THandleParams (..), TransportError (..), TransportPeer (..), supportedParameters) import Simplex.Messaging.Transport.Client (TransportClientConfig, TransportHost, alpn) import Simplex.Messaging.Transport.HTTP2 import Simplex.Messaging.Transport.HTTP2.Client @@ -96,6 +96,12 @@ defaultXFTPClientConfig = clientALPN = Just supportedXFTPhandshakes } +http2XFTPClientError :: HTTP2ClientError -> XFTPClientError +http2XFTPClientError = \case + HCResponseTimeout -> PCEResponseTimeout + HCNetworkError -> PCENetworkError + HCIOError e -> PCEIOError e + getXFTPClient :: TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do let tcConfig = (transportClientConfig xftpNetworkConfig) {alpn = clientALPN} @@ -112,8 +118,7 @@ getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, logDebug $ "Client negotiated handshake protocol: " <> tshow sessionALPN thParams@THandleParams {thVersion} <- case sessionALPN of Just "xftp/1" -> xftpClientHandshakeV1 serverVRange keyHash http2Client thParams0 - Nothing -> pure thParams0 - _ -> throwError $ PCETransportError (TEHandshake VERSION) + _ -> pure thParams0 logDebug $ "Client negotiated protocol: " <> tshow thVersion let c = XFTPClient {http2Client, thParams, transportSession, config} atomically $ writeTVar clientVar $ Just c @@ -130,15 +135,15 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session getServerHandshake = do let helloReq = H.requestNoBody "POST" "/" [] HTTP2Response {respBody = HTTP2Body {bodyHead = shsBody}} <- - liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c helloReq Nothing - liftHS . smpDecode =<< liftHS (C.unPad shsBody) + liftError' http2XFTPClientError $ sendRequest c helloReq Nothing + liftTransportErr (TEHandshake PARSE) . smpDecode =<< liftTransportErr TEBadBlock (C.unPad shsBody) processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionXFTP, C.PublicKeyX25519) processServerHandshake XFTPServerHandshake {xftpVersionRange, sessionId = serverSessId, authPubKey = serverAuth} = do - unless (sessionId == serverSessId) $ throwError $ PCEResponseError SESSION + unless (sessionId == serverSessId) $ throwError $ PCETransportError TEBadSession case xftpVersionRange `compatibleVersion` serverVRange of - Nothing -> throwError $ PCEResponseError HANDSHAKE + Nothing -> throwError $ PCETransportError (TEHandshake VERSION) Just (Compatible v) -> - fmap (v,) . liftHS $ do + fmap (v,) . liftTransportErr (TEHandshake BAD_AUTH) $ do let (X.CertificateChain cert, exact) = serverAuth case cert of [_leaf, ca] | XV.Fingerprint kh == XV.getFingerprint ca X.HashSHA256 -> pure () @@ -147,11 +152,11 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session C.x509ToPublic (pubKey, []) >>= C.pubKey sendClientHandshake :: XFTPClientHandshake -> ExceptT XFTPClientError IO () sendClientHandshake chs = do - chs' <- liftHS $ C.pad (smpEncode chs) xftpBlockSize + chs' <- liftTransportErr TELargeMsg $ C.pad (smpEncode chs) xftpBlockSize let chsReq = H.requestBuilder "POST" "/" [] $ byteString chs' - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftError' (const $ PCEResponseError HANDSHAKE) $ sendRequest c chsReq Nothing - unless (B.null bodyHead) $ throwError $ PCEResponseError HANDSHAKE - liftHS = liftEitherWith (const $ PCEResponseError HANDSHAKE) + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftError' http2XFTPClientError $ sendRequest c chsReq Nothing + unless (B.null bodyHead) $ throwError $ PCETransportError TEBadBlock + liftTransportErr e = liftEitherWith (const $ PCETransportError e) closeXFTPClient :: XFTPClient -> IO () closeXFTPClient XFTPClient {http2Client} = closeHTTP2Client http2Client diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index 7b6787a43..6b9c81a26 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -67,6 +68,9 @@ import Simplex.Messaging.Version (isCompatible) import System.Exit (exitFailure) import System.FilePath (()) import System.IO (hPrint, hPutStrLn, universalNewlineMode) +#ifdef slow_servers +import System.Random (getStdRandom, randomR) +#endif import UnliftIO import UnliftIO.Concurrent (threadDelay) import UnliftIO.Directory (doesFileExist, removeFile, renameFile) @@ -136,6 +140,9 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira let authPubKey = (chain, C.signX509 serverSignKey $ C.publicToX509 k) let hs = XFTPServerHandshake {xftpVersionRange = supportedFileServerVRange, sessionId, authPubKey} shs <- encodeXftp hs +#ifdef slow_servers + lift randomDelay +#endif liftIO . sendResponse $ H.responseBuilder N.ok200 [] shs pure Nothing processClientHandshake pk = do @@ -147,6 +154,9 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira unless (xftpVersion `isCompatible` supportedFileServerVRange) $ throwError HANDSHAKE let auth = THAuthServer {serverPrivKey = pk, sessSecret' = Nothing} atomically $ TM.insert sessionId (HandshakeAccepted auth xftpVersion) sessions +#ifdef slow_servers + lift randomDelay +#endif liftIO . sendResponse $ H.responseNoBody N.ok200 [] pure Nothing sendError :: XFTPErrorType -> M (Maybe (THandleParams XFTPVersion 'TServer)) @@ -310,6 +320,9 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea where sendXFTPResponse (corrId, fId, resp) serverFile_ = do let t_ = xftpEncodeTransmission thParams (corrId, fId, resp) +#ifdef slow_servers + randomDelay +#endif liftIO $ sendResponse $ H.responseStreaming N.ok200 [] $ streamBody t_ where streamBody t_ send done = do @@ -324,6 +337,15 @@ processRequest XFTPTransportRequest {thParams, reqBody = body@HTTP2Body {bodyHea withFile filePath ReadMode $ \h -> sendEncFile h send sbState (fromIntegral fileSize) done +#ifdef slow_servers +randomDelay :: M () +randomDelay = do + d <- asks $ responseDelay . config + when (d > 0) $ do + pc <- getStdRandom (randomR (-200, 200)) + threadDelay $ (d * (1000 + pc)) `div` 1000 +#endif + data VerificationResult = VRVerified XFTPRequest | VRFailed verifyXFTPTransmission :: Maybe (THandleAuth 'TServer, C.CbNonce) -> Maybe TransmissionAuth -> ByteString -> XFTPFileId -> FileCmd -> M VerificationResult diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index 414bfb4c4..76cafcad4 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -66,7 +66,8 @@ data XFTPServerConfig = XFTPServerConfig logStatsStartTime :: Int64, serverStatsLogFile :: FilePath, serverStatsBackupFile :: Maybe FilePath, - transportConfig :: TransportServerConfig + transportConfig :: TransportServerConfig, + responseDelay :: Int } defaultInactiveClientExpiration :: ExpirationConfig diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index d53b3f4fa..271259451 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -182,7 +182,8 @@ xftpServerCLI cfgPath logPath = do defaultTransportServerConfig { logTLSErrors = fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini, alpn = Just supportedXFTPhandshakes - } + }, + responseDelay = 0 } data CliCommand diff --git a/tests/XFTPAgent.hs b/tests/XFTPAgent.hs index 88786bb40..2deb1c655 100644 --- a/tests/XFTPAgent.hs +++ b/tests/XFTPAgent.hs @@ -46,7 +46,11 @@ import XFTPClient xftpAgentTests :: Spec xftpAgentTests = around_ testBracket . describe "agent XFTP API" $ do - it "should send and receive file" testXFTPAgentSendReceive + it "should send and receive file" $ withXFTPServer testXFTPAgentSendReceive + -- uncomment CPP option slow_servers and run hpack to run this test + xit "should send and receive file with slow server responses" $ + withXFTPServerCfg testXFTPServerConfig {responseDelay = 500000} $ + \_ -> testXFTPAgentSendReceive it "should send and receive with encrypted local files" testXFTPAgentSendReceiveEncrypted it "should send and receive large file with a redirect" testXFTPAgentSendReceiveRedirect it "should send and receive small file without a redirect" testXFTPAgentSendReceiveNoRedirect @@ -100,7 +104,7 @@ checkProgress (prev, expected) (progress, total) loop | otherwise = pure () testXFTPAgentSendReceive :: HasCallStack => IO () -testXFTPAgentSendReceive = withXFTPServer $ do +testXFTPAgentSendReceive = do filePath <- createRandomFile -- send file, delete snd file internally (rfd1, rfd2) <- withAgent 1 agentCfg initAgentServers testDB $ \sndr -> runRight $ do diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index 5f38cc639..a3ee9dd1f 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -122,7 +122,8 @@ testXFTPServerConfig_ alpn = logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", serverStatsBackupFile = Nothing, - transportConfig = defaultTransportServerConfig {alpn} + transportConfig = defaultTransportServerConfig {alpn}, + responseDelay = 0 } testXFTPClientConfig :: XFTPClientConfig From 0b5ab3a374ff0ed9719a77fdda03a296e49eb78b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 14:23:21 +0100 Subject: [PATCH 103/107] 5.7.6.0 --- CHANGELOG.md | 5 +++++ package.yaml | 2 +- simplexmq.cabal | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b521e0e9..6d6f90891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 5.7.6 + +XFTP agent: +- treat XFTP handshake timeouts and network errors as temporary, to retry file operations. + # 5.7.5 SMP agent: diff --git a/package.yaml b/package.yaml index 0eaa36d79..8277c7ab1 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.7.5.0 +version: 5.7.6.0 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 2e7d7bb53..d7ccc0d1d 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.7.5.0 +version: 5.7.6.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From e1017e2a7ffff80a779f66fbb99189849b68b547 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 14:25:57 +0100 Subject: [PATCH 104/107] 5.8.0.9 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 7b9486ff4..de34cb103 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.8 +version: 5.8.0.9 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index 31dfff436..ce62b8446 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.8 +version: 5.8.0.9 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From d28b17e787e2729768d924f5231583fb7d112d93 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 22:18:28 +0100 Subject: [PATCH 105/107] xftp server: send HTTP2 error as timeout error to the client so it is treated as temporary (#1186) * xftp server: log file reception error * report HTTP2 error as timeout error * reduce timeout to 5 min * process timeout error in protocol response * log warning on timeout/HTTP2 error --- src/Simplex/FileTransfer/Client.hs | 10 ++-------- src/Simplex/FileTransfer/Server.hs | 2 +- src/Simplex/FileTransfer/Server/Main.hs | 2 +- src/Simplex/FileTransfer/Transport.hs | 14 +++++++++++--- src/Simplex/Messaging/Agent/Client.hs | 15 +++++++++------ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index 2454a1d57..44d1b596b 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -98,12 +98,6 @@ defaultXFTPClientConfig = clientALPN = Just supportedXFTPhandshakes } -http2XFTPClientError :: HTTP2ClientError -> XFTPClientError -http2XFTPClientError = \case - HCResponseTimeout -> PCEResponseTimeout - HCNetworkError -> PCENetworkError - HCIOError e -> PCEIOError e - getXFTPClient :: TransportSession FileResponse -> XFTPClientConfig -> (XFTPClient -> IO ()) -> IO (Either XFTPClientError XFTPClient) getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpNetworkConfig, serverVRange} disconnected = runExceptT $ do let username = proxyUsername transportSession @@ -140,7 +134,7 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session getServerHandshake = do let helloReq = H.requestNoBody "POST" "/" [] HTTP2Response {respBody = HTTP2Body {bodyHead = shsBody}} <- - liftError' http2XFTPClientError $ sendRequest c helloReq Nothing + liftError' xftpClientError $ sendRequest c helloReq Nothing liftTransportErr (TEHandshake PARSE) . smpDecode =<< liftTransportErr TEBadBlock (C.unPad shsBody) processServerHandshake :: XFTPServerHandshake -> ExceptT XFTPClientError IO (VersionRangeXFTP, C.PublicKeyX25519) processServerHandshake XFTPServerHandshake {xftpVersionRange, sessionId = serverSessId, authPubKey = serverAuth} = do @@ -159,7 +153,7 @@ xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {session sendClientHandshake chs = do chs' <- liftTransportErr TELargeMsg $ C.pad (smpEncode chs) xftpBlockSize let chsReq = H.requestBuilder "POST" "/" [] $ byteString chs' - HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftError' http2XFTPClientError $ sendRequest c chsReq Nothing + HTTP2Response {respBody = HTTP2Body {bodyHead}} <- liftError' xftpClientError $ sendRequest c chsReq Nothing unless (B.null bodyHead) $ throwError $ PCETransportError TEBadBlock liftTransportErr e = liftEitherWith (const $ PCETransportError e) diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index 7eb65ced2..1ed4894ec 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -479,7 +479,7 @@ processXFTPRequest HTTP2Body {bodyPart} = \case pure $ FRErr e receiveChunk spec = do t <- asks $ fileTimeout . config - liftIO $ fromMaybe (Left TIMEOUT) <$> timeout t (runExceptT (receiveFile getBody spec) `catchAll_` pure (Left FILE_IO)) + liftIO $ fromMaybe (Left TIMEOUT) <$> timeout t (runExceptT $ receiveFile getBody spec) sendServerFile :: FileRec -> RcvPublicDhKey -> M (FileResponse, Maybe ServerFile) sendServerFile FileRec {senderId, filePath, fileInfo = FileInfo {size}} rDhKey = do readTVarIO filePath >>= \case diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index d31295cd2..76b1f157a 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -166,7 +166,7 @@ xftpServerCLI cfgPath logPath = do defaultFileExpiration { ttl = 3600 * readIniDefault defFileExpirationHours "STORE_LOG" "expire_files_hours" ini }, - fileTimeout = 10 * 60 * 1000000, -- 10 mins to send 4mb chunk + fileTimeout = 5 * 60 * 1000000, -- 5 mins to send 4mb chunk inactiveClientExpiration = settingIsOn "INACTIVE_CLIENTS" "disconnect" ini $> ExpirationConfig diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index 2f0a5de4f..935fa1c42 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -34,6 +34,7 @@ where import Control.Applicative ((<|>)) import qualified Control.Exception as E +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class @@ -46,8 +47,10 @@ import Data.ByteString.Builder (Builder, byteString) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Functor (($>)) import Data.Word (Word16, Word32) import qualified Data.X509 as X +import Network.HTTP2.Client (HTTP2Error) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.Messaging.Encoding @@ -56,7 +59,7 @@ import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol (CommandError) import Simplex.Messaging.Transport (SessionId, THandle (..), THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.File -import Simplex.Messaging.Util (bshow) +import Simplex.Messaging.Util (bshow, tshow) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal import System.IO (Handle, IOMode (..), withFile) @@ -145,9 +148,14 @@ sendEncFile h send = go go sbState' $ sz - fromIntegral (B.length ch) receiveFile :: (Int -> IO ByteString) -> XFTPRcvChunkSpec -> ExceptT XFTPErrorType IO () -receiveFile getBody = receiveFile_ receive +receiveFile getBody chunk = ExceptT $ runExceptT (receiveFile_ receive chunk) `E.catches` handlers where receive h sz = hReceiveFile getBody h sz >>= \sz' -> pure $ if sz' == 0 then Right () else Left SIZE + handlers = + [ E.Handler $ \(e :: HTTP2Error) -> logWarn (err e) $> Left TIMEOUT, + E.Handler $ \(e :: E.SomeException) -> logError (err e) $> Left FILE_IO + ] + err e = "receiveFile error: " <> tshow e receiveEncFile :: (Int -> IO ByteString) -> LC.SbState -> XFTPRcvChunkSpec -> ExceptT XFTPErrorType IO () receiveEncFile getBody = receiveFile_ . receive @@ -213,7 +221,7 @@ data XFTPErrorType HAS_FILE | -- | file IO error FILE_IO - | -- | file sending timeout + | -- | file sending or receiving timeout TIMEOUT | -- | bad redirect data REDIRECT {redirectError :: String} diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index e63ab5d1d..e18888244 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -188,6 +188,7 @@ import qualified Simplex.FileTransfer.Client as X import Simplex.FileTransfer.Description (ChunkReplicaId (..), FileDigest (..), kb) import Simplex.FileTransfer.Protocol (FileInfo (..), FileResponse) import Simplex.FileTransfer.Transport (XFTPErrorType (DIGEST), XFTPRcvChunkSpec (..), XFTPVersion) +import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (DeletedSndChunkReplica (..), NewSndChunkReplica (..), RcvFileChunkReplica (..), SndFileChunk (..), SndFileChunkReplica (..)) import Simplex.FileTransfer.Util (uniqueCombine) import Simplex.Messaging.Agent.Env.SQLite @@ -1315,6 +1316,7 @@ temporaryAgentError :: AgentErrorType -> Bool temporaryAgentError = \case BROKER _ e -> tempBrokerError e SMP _ (SMP.PROXY (SMP.BROKER e)) -> tempBrokerError e + XFTP _ XFTP.TIMEOUT -> True PROXY _ _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER e))) -> tempBrokerError e PROXY _ _ (ProxyProtocolError (SMP.PROXY SMP.NO_SESSION)) -> True INACTIVE -> True @@ -2081,12 +2083,13 @@ getAgentQueuesInfo AgentClient {msgQ, subQ, smpClients} = do where getClientQueuesInfo :: SMPClientVar -> IO (Int, UTCTime, ClientInfo) getClientQueuesInfo SessionVar {sessionVar, sessionVarId, sessionVarTs} = do - clientInfo <- atomically (tryReadTMVar sessionVar) >>= \case - Just (Right c) -> do - (sndQInfo, rcvQInfo) <- getProtocolClientQueuesInfo $ protocolClient c - pure ClientInfoQueues {sndQInfo, rcvQInfo} - Just (Left e) -> pure $ ClientInfoError e - Nothing -> pure ClientInfoConnecting + clientInfo <- + atomically (tryReadTMVar sessionVar) >>= \case + Just (Right c) -> do + (sndQInfo, rcvQInfo) <- getProtocolClientQueuesInfo $ protocolClient c + pure ClientInfoQueues {sndQInfo, rcvQInfo} + Just (Left e) -> pure $ ClientInfoError e + Nothing -> pure ClientInfoConnecting pure (sessionVarId, sessionVarTs, clientInfo) $(J.deriveJSON defaultJSON ''AgentLocks) From 2e4f507919dd3a08e5c4106fde193fee4414de3c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 May 2024 22:20:30 +0100 Subject: [PATCH 106/107] 5.8.0.10 --- package.yaml | 2 +- simplexmq.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index de34cb103..662cf8a0a 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplexmq -version: 5.8.0.9 +version: 5.8.0.10 synopsis: SimpleXMQ message broker description: | This package includes <./docs/Simplex-Messaging-Server.html server>, diff --git a/simplexmq.cabal b/simplexmq.cabal index ce62b8446..4974e9f75 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplexmq -version: 5.8.0.9 +version: 5.8.0.10 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 44b8f265ae1d9a8dffe4b74605b5c68d05afcfe6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Jun 2024 10:01:59 +0100 Subject: [PATCH 107/107] 5.8.0: changelog --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6f90891..e161a26df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 5.8.0 + +Version 5.8.0.10 + +SMP server and client: +- protocol extension to forward messages to the destination servers, to protect sending client IP address and transport session. + +Agent: +- process timed out subscription responses to reduce the number of resubscriptions. +- avoid sending messages and commands when waiting for response timed out (except batched SUB and DEL commands). +- fix issue with stuck message reception on slow connection (when response to ACK timed out, and the new message was not processed until resubscribed). +- fix issue when temporary file sending or receiving error was treated as permanent. + +SMP server: +- include OK responses to all batched SUB requests to reduce subscription timeouts. + +XFTP server: +- report file upload timeout as TIMEOUT, to avoid delivery failure. + # 5.7.6 XFTP agent: