From fc581bf72909cee57e64d5a6a4dcb5cf93c004a3 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:42:14 +0000 Subject: [PATCH 01/13] github/workflows: fix reproduce schedule (#1587) --- .github/workflows/reproduce-schedule.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml index a13c1fca2..7e24e7ca6 100644 --- a/.github/workflows/reproduce-schedule.yml +++ b/.github/workflows/reproduce-schedule.yml @@ -25,7 +25,7 @@ jobs: - name: Execute reproduce script run: | - ${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG" + ${GITHUB_WORKSPACE}/scripts/simplexmq-reproduce-builds.sh "$TAG" || : - name: Check if build has been reproduced env: From 40fc09a93de761842a9cc8f89ae1557eee426130 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 18 Jul 2025 09:26:25 +0100 Subject: [PATCH 02/13] smp server: additional stat counter for ntf credentials created together with the queue (#1589) * smp server: additional stat counter for ntf credentials created together with the queue * fix prometheus * fix test * fix qSub --- src/Simplex/Messaging/Server.hs | 6 +++--- src/Simplex/Messaging/Server/Prometheus.hs | 11 ++++++++--- src/Simplex/Messaging/Server/Stats.hs | 10 ++++++++++ tests/ServerTests.hs | 6 +++--- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 1069fafdf..a439e8709 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1512,7 +1512,7 @@ client stats <- asks serverStats incStat $ qCreated stats incStat $ qCount stats - when (isJust ntf) $ incStat $ ntfCreated stats + when (isJust ntf) $ incStat $ ntfNewCreated stats case subMode of SMOnlyCreate -> pure () SMSubscribe -> subscribeNewQueue rcvId qr -- no need to check if message is available, it's a new queue @@ -1580,7 +1580,7 @@ client subscribeQueueAndDeliver :: StoreQueue s -> QueueRec -> M s ResponseAndMessage subscribeQueueAndDeliver q qr = liftIO (TM.lookupIO entId $ subscriptions clnt) >>= \case - Nothing -> subscribeRcvQueue qr >>= deliver True + Nothing -> subscribeRcvQueue qr >>= deliver False Just s@Sub {subThread} -> do stats <- asks serverStats case subThread of @@ -1590,7 +1590,7 @@ client pure (err (CMD PROHIBITED), Nothing) _ -> do incStat $ qSubDuplicate stats - atomically (writeTVar (delivered s) Nothing) >> deliver False s + atomically (writeTVar (delivered s) Nothing) >> deliver True s where deliver :: Bool -> Sub -> M s ResponseAndMessage deliver hasSub sub = do diff --git a/src/Simplex/Messaging/Server/Prometheus.hs b/src/Simplex/Messaging/Server/Prometheus.hs index cdcf10cfd..859587b60 100644 --- a/src/Simplex/Messaging/Server/Prometheus.hs +++ b/src/Simplex/Messaging/Server/Prometheus.hs @@ -82,6 +82,7 @@ prometheusMetrics sm rtm ts = _qSubEnd, _qSubEndB, _ntfCreated, + _ntfNewCreated, _ntfDeleted, _ntfDeletedB, _ntfSub, @@ -262,15 +263,19 @@ prometheusMetrics sm rtm ts = \simplex_smp_messages_notify_get_errors{type=\"duplicate\"} " <> mshow _msgGetDuplicate <> "\n# msgGetDuplicate\n\ \simplex_smp_messages_notify_get_errors{type=\"prohibited\"} " <> mshow _msgGetProhibited <> "\n# msgGetProhibited\n\ \\n\ - \# HELP simplex_smp_queues_notify_created Created queues with notification flag (client).\n\ + \# HELP simplex_smp_queues_notify_created Created queue notification credentials.\n\ \# TYPE simplex_smp_queues_notify_created counter\n\ \simplex_smp_queues_notify_created " <> mshow _ntfCreated <> "\n# ntfCreated\n\ \\n\ - \# HELP simplex_smp_queues_notify_deleted Deleted queues with notification flag (client).\n\ + \# HELP simplex_smp_queues_notify_new_created Created new queues with notification credentials.\n\ + \# TYPE simplex_smp_queues_notify_new_created counter\n\ + \simplex_smp_queues_notify_new_created " <> mshow _ntfNewCreated <> "\n# ntfNewCreated\n\ + \\n\ + \# HELP simplex_smp_queues_notify_deleted Deleted queue notification credentials.\n\ \# TYPE simplex_smp_queues_notify_deleted counter\n\ \simplex_smp_queues_notify_deleted " <> mshow _ntfDeleted <> "\n# ntfDeleted\n\ \\n\ - \# HELP simplex_smp_queues_notify_deleted_batch Deleted batched queues with notification flag (client).\n\ + \# HELP simplex_smp_queues_notify_deleted_batch Deleted batched queue notification credentials.\n\ \# TYPE simplex_smp_queues_notify_deleted_batch counter\n\ \simplex_smp_queues_notify_deleted_batch " <> mshow _ntfDeletedB <> "\n# ntfDeletedB\n\ \\n\ diff --git a/src/Simplex/Messaging/Server/Stats.hs b/src/Simplex/Messaging/Server/Stats.hs index 50c611009..4af195295 100644 --- a/src/Simplex/Messaging/Server/Stats.hs +++ b/src/Simplex/Messaging/Server/Stats.hs @@ -48,6 +48,7 @@ data ServerStats = ServerStats qSubEnd :: IORef Int, qSubEndB :: IORef Int, ntfCreated :: IORef Int, + ntfNewCreated :: IORef Int, -- credentials created at the time of queue creation ntfDeleted :: IORef Int, ntfDeletedB :: IORef Int, ntfSub :: IORef Int, @@ -107,6 +108,7 @@ data ServerStatsData = ServerStatsData _qSubEnd :: Int, _qSubEndB :: Int, _ntfCreated :: Int, + _ntfNewCreated :: Int, _ntfDeleted :: Int, _ntfDeletedB :: Int, _ntfSub :: Int, @@ -167,6 +169,7 @@ newServerStats ts = do qSubEnd <- newIORef 0 qSubEndB <- newIORef 0 ntfCreated <- newIORef 0 + ntfNewCreated <- newIORef 0 ntfDeleted <- newIORef 0 ntfDeletedB <- newIORef 0 ntfSub <- newIORef 0 @@ -224,6 +227,7 @@ newServerStats ts = do qSubEnd, qSubEndB, ntfCreated, + ntfNewCreated, ntfDeleted, ntfDeletedB, ntfSub, @@ -283,6 +287,7 @@ getServerStatsData s = do _qSubEnd <- readIORef $ qSubEnd s _qSubEndB <- readIORef $ qSubEndB s _ntfCreated <- readIORef $ ntfCreated s + _ntfNewCreated <- readIORef $ ntfNewCreated s _ntfDeleted <- readIORef $ ntfDeleted s _ntfDeletedB <- readIORef $ ntfDeletedB s _ntfSub <- readIORef $ ntfSub s @@ -340,6 +345,7 @@ getServerStatsData s = do _qSubEnd, _qSubEndB, _ntfCreated, + _ntfNewCreated, _ntfDeleted, _ntfDeletedB, _ntfSub, @@ -400,6 +406,7 @@ setServerStats s d = do writeIORef (qSubEnd s) $! _qSubEnd d writeIORef (qSubEndB s) $! _qSubEndB d writeIORef (ntfCreated s) $! _ntfCreated d + writeIORef (ntfNewCreated s) $! _ntfNewCreated d writeIORef (ntfDeleted s) $! _ntfDeleted d writeIORef (ntfDeletedB s) $! _ntfDeletedB d writeIORef (ntfSub s) $! _ntfSub d @@ -460,6 +467,7 @@ instance StrEncoding ServerStatsData where "qSubEnd=" <> strEncode (_qSubEnd d), "qSubEndB=" <> strEncode (_qSubEndB d), "ntfCreated=" <> strEncode (_ntfCreated d), + "ntfNewCreated=" <> strEncode (_ntfNewCreated d), "ntfDeleted=" <> strEncode (_ntfDeleted d), "ntfDeletedB=" <> strEncode (_ntfDeletedB d), "ntfSub=" <> strEncode (_ntfSub d), @@ -523,6 +531,7 @@ instance StrEncoding ServerStatsData where _qSubEnd <- opt "qSubEnd=" _qSubEndB <- opt "qSubEndB=" _ntfCreated <- opt "ntfCreated=" + _ntfNewCreated <- opt "ntfNewCreated=" _ntfDeleted <- opt "ntfDeleted=" _ntfDeletedB <- opt "ntfDeletedB=" _ntfSub <- opt "ntfSub=" @@ -590,6 +599,7 @@ instance StrEncoding ServerStatsData where _qSubEnd, _qSubEndB, _ntfCreated, + _ntfNewCreated, _ntfDeleted, _ntfDeletedB, _ntfSub, diff --git a/tests/ServerTests.hs b/tests/ServerTests.hs index 5ff65c9c4..407b9f182 100644 --- a/tests/ServerTests.hs +++ b/tests/ServerTests.hs @@ -795,7 +795,7 @@ testRestoreMessages = pure () rId <- readTVarIO recipientId logSize testStoreLogFile `shouldReturn` 2 - logSize testServerStatsBackupFile `shouldReturn` 94 + logSize testServerStatsBackupFile `shouldReturn` 95 Right stats1 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats1 [rId] 5 1 withSmpServerConfigOn at cfg' testPort . runTest t $ \h -> do @@ -811,7 +811,7 @@ testRestoreMessages = logSize testStoreLogFile `shouldReturn` (if compacting then 1 else 2) -- the last message is not removed because it was not ACK'd -- logSize testStoreMsgsFile `shouldReturn` 3 - logSize testServerStatsBackupFile `shouldReturn` 94 + logSize testServerStatsBackupFile `shouldReturn` 95 Right stats2 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats2 [rId] 5 3 @@ -829,7 +829,7 @@ testRestoreMessages = pure () logSize testStoreLogFile `shouldReturn` (if compacting then 1 else 2) removeFile testStoreLogFile - logSize testServerStatsBackupFile `shouldReturn` 94 + logSize testServerStatsBackupFile `shouldReturn` 95 Right stats3 <- strDecode <$> B.readFile testServerStatsBackupFile checkStats stats3 [rId] 5 5 removeFileIfExists testStoreMsgsFile From c0f357d817d01b130706386bc27ac8836072e4e5 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 21 Jul 2025 15:19:43 +0100 Subject: [PATCH 03/13] servers: control port session improvements (#1591) * servers: prohibit changing role during control port session * quota for blocked queues * allow disabling blocking and quota * fix test * fix INI file --- src/Simplex/FileTransfer/Server.hs | 9 ++-- src/Simplex/Messaging/Notifications/Server.hs | 7 +-- src/Simplex/Messaging/Server.hs | 47 +++++++++++++------ src/Simplex/Messaging/Server/Control.hs | 2 +- src/Simplex/Messaging/Server/Env/STM.hs | 1 + src/Simplex/Messaging/Server/Main.hs | 1 + src/Simplex/Messaging/Server/Main/Init.hs | 7 ++- tests/SMPClient.hs | 1 + 8 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index a5e5727e4..c12c2b5f6 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -54,7 +54,7 @@ import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BlockingInfo, EntityId (..), RcvPublicAuthKey, RcvPublicDhKey, RecipientId, SignedTransmission, pattern NoEntity) -import Simplex.Messaging.Server (dummyVerifyCmd, verifyCmdAuthorization) +import Simplex.Messaging.Server (controlPortAuth, dummyVerifyCmd, verifyCmdAuthorization) import Simplex.Messaging.Server.Control (CPClientRole (..)) import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.QueueStore (RoundedSystemTime, ServerEntityStatus (..), getRoundedSystemTime) @@ -277,12 +277,9 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira CPSkip -> False _ -> True processCP h role = \case - CPAuth auth -> atomically $ writeTVar role $! newRole cfg + CPAuth auth -> controlPortAuth h user admin role auth where - newRole XFTPServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} - | Just auth == admin = CPRAdmin - | Just auth == user = CPRUser - | otherwise = CPRNone + XFTPServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} = cfg CPStatsRTS -> E.tryAny getRTSStats >>= either (hPrint h) (hPrint h) CPDelete fileId -> withUserRole $ unliftIO u $ do fs <- asks store diff --git a/src/Simplex/Messaging/Notifications/Server.hs b/src/Simplex/Messaging/Notifications/Server.hs index c856b5249..ac274dc08 100644 --- a/src/Simplex/Messaging/Notifications/Server.hs +++ b/src/Simplex/Messaging/Notifications/Server.hs @@ -364,12 +364,9 @@ ntfServer cfg@NtfServerConfig {transports, transportConfig = tCfg, startOptions} CPSkip -> False _ -> True processCP h role = \case - CPAuth auth -> atomically $ writeTVar role $! newRole cfg + CPAuth auth -> controlPortAuth h user admin role auth where - newRole NtfServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} - | Just auth == admin = CPRAdmin - | Just auth == user = CPRUser - | otherwise = CPRNone + NtfServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} = cfg CPStats -> withUserRole $ do ss <- unliftIO u $ asks serverStats let getStat :: (NtfServerStats -> IORef a) -> IO a diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index a439e8709..801597b48 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -31,6 +31,7 @@ module Simplex.Messaging.Server ( runSMPServer, runSMPServerBlocking, + controlPortAuth, importMessages, exportMessages, printMessageStats, @@ -558,6 +559,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt msgNtfNoSub' <- atomicSwapIORef (msgNtfNoSub ss) 0 msgNtfLost' <- atomicSwapIORef (msgNtfLost ss) 0 msgNtfExpired' <- atomicSwapIORef (msgNtfExpired ss) 0 + _qBlocked <- atomicSwapIORef (qBlocked ss) 0 -- not logged, only reset pRelays' <- getResetProxyStatsData pRelays pRelaysOwn' <- getResetProxyStatsData pRelaysOwn pMsgFwds' <- getResetProxyStatsData pMsgFwds @@ -770,12 +772,9 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt CPSkip -> False _ -> True processCP h role = \case - CPAuth auth -> atomically $ writeTVar role $! newRole cfg + CPAuth auth -> controlPortAuth h user admin role auth where - newRole ServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} - | Just auth == admin = CPRAdmin - | Just auth == user = CPRUser - | otherwise = CPRNone + ServerConfig {controlPortUserAuth = user, controlPortAdminAuth = admin} = cfg CPSuspend -> withAdminRole $ hPutStrLn h "suspend not implemented" CPResume -> withAdminRole $ hPutStrLn h "resume not implemented" CPClients -> withAdminRole $ do @@ -964,7 +963,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt SubPending -> (c1, c2 + 1, c3, c4) SubThread _ -> (c1, c2, c3 + 1, c4) ProhibitSub -> pure (c1, c2, c3, c4 + 1) - CPDelete sId -> withUserRole $ unliftIO u $ do + CPDelete sId -> withAdminRole $ unliftIO u $ do st <- asks msgStore r <- liftIO $ runExceptT $ do q <- ExceptT $ getQueue st SSender sId @@ -983,14 +982,20 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt "status: " <> show status <> ", updatedAt: " <> show updatedAt <> ", queueMode: " <> show queueMode CPBlock sId info -> withUserRole $ unliftIO u $ do st <- asks msgStore - r <- liftIO $ runExceptT $ do - q <- ExceptT $ getQueue st SSender sId - ExceptT $ blockQueue (queueStore st) q info - case r of - Left e -> liftIO $ hPutStrLn h $ "error: " <> show e - Right () -> do - incStat . qBlocked =<< asks serverStats - liftIO $ hPutStrLn h "ok" + stats <- asks serverStats + blocked <- liftIO $ readIORef $ qBlocked stats + let quota = dailyBlockQueueQuota cfg + if blocked >= quota && quota /= 0 + then liftIO $ hPutStrLn h $ "error: reached limit of " <> show quota <> " queues blocked daily" + else do + r <- liftIO $ runExceptT $ do + q <- ExceptT $ getQueue st SSender sId + ExceptT $ blockQueue (queueStore st) q info + case r of + Left e -> liftIO $ hPutStrLn h $ "error: " <> show e + Right () -> do + incStat $ qBlocked stats + liftIO $ hPutStrLn h "ok" CPUnblock sId -> withUserRole $ unliftIO u $ do st <- asks msgStore r <- liftIO $ runExceptT $ do @@ -1045,6 +1050,20 @@ runClientTransport h@THandle {params = thParams@THandleParams {sessionId}} = do where hasSubs ServerSubscribers {subClients} = IS.member clientId <$> readTVarIO subClients +controlPortAuth :: Handle -> Maybe BasicAuth -> Maybe BasicAuth -> TVar CPClientRole -> BasicAuth -> IO () +controlPortAuth h user admin role auth = do + readTVarIO role >>= \case + CPRNone -> do + atomically $ writeTVar role $! newRole + hPutStrLn h $ currentRole newRole + r -> hPutStrLn h $ currentRole r <> if r == newRole then "" else ", start new session to change." + where + currentRole r = "Current role is " <> show r + newRole + | Just auth == admin = CPRAdmin + | Just auth == user = CPRUser + | otherwise = CPRNone + clientDisconnected :: forall s. Client s -> M s () clientDisconnected c@Client {clientId, subscriptions, ntfSubscriptions, serviceSubsCount, ntfServiceSubsCount, connected, clientTHParams = THandleParams {sessionId, thAuth}, endThreads} = do labelMyThread . B.unpack $ "client $" <> encode sessionId <> " disc" diff --git a/src/Simplex/Messaging/Server/Control.hs b/src/Simplex/Messaging/Server/Control.hs index 318bce1cc..7c6565285 100644 --- a/src/Simplex/Messaging/Server/Control.hs +++ b/src/Simplex/Messaging/Server/Control.hs @@ -8,7 +8,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth, BlockingInfo, SenderId) data CPClientRole = CPRNone | CPRUser | CPRAdmin - deriving (Eq) + deriving (Eq, Show) data ControlProtocol = CPAuth BasicAuth diff --git a/src/Simplex/Messaging/Server/Env/STM.hs b/src/Simplex/Messaging/Server/Env/STM.hs index 4d0de07a6..d0f1a84fd 100644 --- a/src/Simplex/Messaging/Server/Env/STM.hs +++ b/src/Simplex/Messaging/Server/Env/STM.hs @@ -149,6 +149,7 @@ data ServerConfig s = ServerConfig -- | control port passwords, controlPortUserAuth :: Maybe BasicAuth, controlPortAdminAuth :: Maybe BasicAuth, + dailyBlockQueueQuota :: Int, -- | time after which the messages can be removed from the queues and check interval, seconds messageExpiration :: Maybe ExpirationConfig, expireMessagesOnStart :: Bool, diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index b2d16d904..17c5de7e3 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -420,6 +420,7 @@ smpServerCLI_ generateSite serveStaticFiles attachStaticFiles cfgPath logPath = 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, + dailyBlockQueueQuota = readIniDefault 20 "AUTH" "daily_block_queue_quota" ini, messageExpiration = Just defaultMessageExpiration diff --git a/src/Simplex/Messaging/Server/Main/Init.hs b/src/Simplex/Messaging/Server/Main/Init.hs index 7b1b320be..9f52d0797 100644 --- a/src/Simplex/Messaging/Server/Main/Init.hs +++ b/src/Simplex/Messaging/Server/Main/Init.hs @@ -107,8 +107,11 @@ iniFileContent cfgPath logPath opts host basicAuth controlPortPwds = ) <> "\n\n" <> (optDisabled controlPortPwds <> "control_port_admin_password: " <> maybe "" fst controlPortPwds <> "\n") - <> (optDisabled controlPortPwds <> "control_port_user_password: " <> maybe "" snd controlPortPwds <> "\n") - <> "\n\ + <> (optDisabled controlPortPwds <> "control_port_user_password: " <> maybe "" snd controlPortPwds <> "\n\n") + <> "# The limit for queues that can be blocked via control port per day, resets at 0:00 UTC.\n\ + \# Set to 0 to disable limit, to -1 to prohibit blocking. Default is 20.\n\ + \# daily_block_queue_quota: 20\n\ + \\n\ \[TRANSPORT]\n\ \# Host is only used to print server address on start.\n\ \# You can specify multiple server ports.\n" diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 50f11e25f..2b18a2d51 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -226,6 +226,7 @@ cfgMS msType = withStoreCfg (testServerStoreConfig msType) $ \serverStoreCfg -> newQueueBasicAuth = Nothing, controlPortUserAuth = Nothing, controlPortAdminAuth = Nothing, + dailyBlockQueueQuota = 20, messageExpiration = Just defaultMessageExpiration, expireMessagesOnStart = True, idleQueueInterval = defaultIdleQueueInterval, From 846be50f72c1bfbbd70a98e9edf25c0a2d9d4234 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 21 Jul 2025 15:22:47 +0100 Subject: [PATCH 04/13] 6.4.1.0 --- CHANGELOG.md | 9 +++++++++ simplexmq.cabal | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8b1f210..a0a0a23a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 6.4.1 + +SMP protocol: +- create notification credentials via NEW command that creates the queue (#1586) + +SMP server: +- control port session improvements (#1591) +- additional stat counter for ntf credentials created together with the queue (#1589) + # 6.4.0 SMP protocol (server/client): diff --git a/simplexmq.cabal b/simplexmq.cabal index bfe546485..eab76a9b2 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -1,7 +1,7 @@ cabal-version: 1.12 name: simplexmq -version: 6.4.0.12 +version: 6.4.1.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 9ab071d62cc73e581d44914ca1ba5fcd727ee6dd Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 30 Jul 2025 08:17:28 +0000 Subject: [PATCH 05/13] ci: fix reproduce-schedule (#1594) --- .github/workflows/reproduce-schedule.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml index 7e24e7ca6..f9b22d9bb 100644 --- a/.github/workflows/reproduce-schedule.yml +++ b/.github/workflows/reproduce-schedule.yml @@ -33,7 +33,7 @@ jobs: user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }} pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }} run: | - if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then + if [ -f "${GITHUB_WORKSPACE}/${TAG}-simplexmq/_sha256sums" ]; then exit 0 else curl --proto '=https' --tlsv1.2 -sSf \ From 4647d69d4b29f06684703917b8e3ff06c8e65a13 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 30 Jul 2025 11:12:57 +0100 Subject: [PATCH 06/13] smp server: do not include previously blocked queues in stats, prevent leak of client threads (#1593) --- src/Simplex/Messaging/Server.hs | 12 +++++++----- src/Simplex/Messaging/Transport/Server.hs | 7 ++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 801597b48..60c05a7e8 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -989,13 +989,15 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt then liftIO $ hPutStrLn h $ "error: reached limit of " <> show quota <> " queues blocked daily" else do r <- liftIO $ runExceptT $ do - q <- ExceptT $ getQueue st SSender sId - ExceptT $ blockQueue (queueStore st) q info + (q, QueueRec {status}) <- ExceptT $ getQueueRec st SSender sId + when (status == EntityActive) $ ExceptT $ blockQueue (queueStore st) q info + pure status case r of Left e -> liftIO $ hPutStrLn h $ "error: " <> show e - Right () -> do + Right EntityActive -> do incStat $ qBlocked stats - liftIO $ hPutStrLn h "ok" + liftIO $ hPutStrLn h "ok, queue blocked" + Right status -> liftIO $ hPutStrLn h $ "ok, already inactive: " <> show status CPUnblock sId -> withUserRole $ unliftIO u $ do st <- asks msgStore r <- liftIO $ runExceptT $ do @@ -1003,7 +1005,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt ExceptT $ unblockQueue (queueStore st) q liftIO $ hPutStrLn h $ case r of Left e -> "error: " <> show e - Right () -> "ok" + Right () -> "ok, queue unblocked" CPSave -> withAdminRole $ withLock' (savingLock srv) "control" $ do hPutStrLn h "saving server state..." unliftIO u $ saveServer False diff --git a/src/Simplex/Messaging/Transport/Server.hs b/src/Simplex/Messaging/Transport/Server.hs index 89a3bf063..00b94ddc5 100644 --- a/src/Simplex/Messaging/Transport/Server.hs +++ b/src/Simplex/Messaging/Transport/Server.hs @@ -49,7 +49,7 @@ import Network.Socket import qualified Network.TLS as T import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Shared -import Simplex.Messaging.Util (catchAll_, labelMyThread, tshow) +import Simplex.Messaging.Util (catchAll_, labelMyThread, tshow, unlessM) import System.Exit (exitFailure) import System.IO.Error (tryIOError) import System.Mem.Weak (Weak, deRefWeak) @@ -172,12 +172,13 @@ runTCPServerSocket (accepted, gracefullyClosed, clients) started getSocket serve E.bracket getSocket (closeServer started clients) $ \sock -> forever . E.bracketOnError (safeAccept sock) (close . fst) $ \(conn, _peer) -> do cId <- atomically $ stateTVar accepted $ \cId -> let cId' = cId + 1 in cId' `seq` (cId', cId') + closed <- newTVarIO False let closeConn _ = do - atomically $ modifyTVar' clients $ IM.delete cId + atomically $ writeTVar closed True >> 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 - atomically $ modifyTVar' clients $ IM.insert cId tId + atomically $ unlessM (readTVar closed) $ 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. From f4e7469f96c0ce8fdbac32a301d39d2129394268 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 30 Jul 2025 16:18:49 +0100 Subject: [PATCH 07/13] xftp server: prometheus metrics (#1595) * xftp server: prometheus metrics * update Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> --- simplexmq.cabal | 1 + src/Simplex/FileTransfer/Server.hs | 35 ++++- src/Simplex/FileTransfer/Server/Env.hs | 2 + src/Simplex/FileTransfer/Server/Main.hs | 7 +- src/Simplex/FileTransfer/Server/Prometheus.hs | 134 ++++++++++++++++++ .../Messaging/Notifications/Server/Main.hs | 3 + src/Simplex/Messaging/Server/Main/Init.hs | 2 +- tests/XFTPClient.hs | 5 + 8 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/Simplex/FileTransfer/Server/Prometheus.hs diff --git a/simplexmq.cabal b/simplexmq.cabal index eab76a9b2..f57d433f8 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -224,6 +224,7 @@ library Simplex.FileTransfer.Server.Control Simplex.FileTransfer.Server.Env Simplex.FileTransfer.Server.Main + Simplex.FileTransfer.Server.Prometheus Simplex.FileTransfer.Server.Stats Simplex.FileTransfer.Server.Store Simplex.FileTransfer.Server.StoreLog diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index c12c2b5f6..48e855f69 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -45,6 +45,7 @@ import Network.Socket import Simplex.FileTransfer.Protocol import Simplex.FileTransfer.Server.Control import Simplex.FileTransfer.Server.Env +import Simplex.FileTransfer.Server.Prometheus import Simplex.FileTransfer.Server.Stats import Simplex.FileTransfer.Server.Store import Simplex.FileTransfer.Server.StoreLog @@ -69,6 +70,7 @@ import Simplex.Messaging.Transport.HTTP2.Server import Simplex.Messaging.Transport.Server (runLocalTCPServer) import Simplex.Messaging.Util import Simplex.Messaging.Version +import System.Environment (lookupEnv) import System.Exit (exitFailure) import System.FilePath (()) import System.IO (hPrint, hPutStrLn, universalNewlineMode) @@ -105,7 +107,14 @@ xftpServer :: XFTPServerConfig -> TMVar Bool -> M () 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 + raceAny_ + ( runServer + : expireFilesThread_ cfg + <> serverStatsThread_ cfg + <> prometheusMetricsThread_ cfg + <> controlPortThread_ cfg + ) + `finally` stopServer where runServer :: M () runServer = do @@ -240,6 +249,30 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira ] liftIO $ threadDelay' interval + prometheusMetricsThread_ :: XFTPServerConfig -> [M ()] + prometheusMetricsThread_ XFTPServerConfig {prometheusInterval = Just interval, prometheusMetricsFile} = + [savePrometheusMetrics interval prometheusMetricsFile] + prometheusMetricsThread_ _ = [] + + savePrometheusMetrics :: Int -> FilePath -> M () + savePrometheusMetrics saveInterval metricsFile = do + labelMyThread "savePrometheusMetrics" + liftIO $ putStrLn $ "Prometheus metrics saved every " <> show saveInterval <> " seconds to " <> metricsFile + ss <- asks serverStats + rtsOpts <- liftIO $ maybe ("set " <> rtsOptionsEnv) T.pack <$> lookupEnv (T.unpack rtsOptionsEnv) + let interval = 1000000 * saveInterval + liftIO $ forever $ do + threadDelay interval + ts <- getCurrentTime + sm <- getFileServerMetrics ss rtsOpts + T.writeFile metricsFile $ xftpPrometheusMetrics sm ts + + getFileServerMetrics :: FileServerStats -> T.Text -> IO FileServerMetrics + getFileServerMetrics ss rtsOptions = do + d <- getFileServerStatsData ss + let fd = periodStatDataCounts $ _filesDownloaded d + pure FileServerMetrics {statsData = d, filesDownloadedPeriods = fd, rtsOptions} + controlPortThread_ :: XFTPServerConfig -> [M ()] controlPortThread_ XFTPServerConfig {controlPort = Just port} = [runCPServer port] controlPortThread_ _ = [] diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index 21e6d06dd..206a5b375 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -64,6 +64,8 @@ data XFTPServerConfig = XFTPServerConfig logStatsStartTime :: Int64, serverStatsLogFile :: FilePath, serverStatsBackupFile :: Maybe FilePath, + prometheusInterval :: Maybe Int, + prometheusMetricsFile :: FilePath, transportConfig :: TransportServerConfig, responseDelay :: Int } diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index e8b818f5b..fcac06cd4 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -30,7 +30,7 @@ import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Server (ServerCredentials (..), mkTransportServerConfig) -import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, tshow) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath (combine) import System.IO (BufferMode (..), hSetBuffering, stderr, stdout) @@ -89,6 +89,9 @@ xftpServerCLI cfgPath logPath = do <> "# Expire files after the specified number of hours.\n" <> ("expire_files_hours: " <> tshow defFileExpirationHours <> "\n\n") <> "log_stats: off\n\ + \\n\ + \# Log interval for real-time Prometheus metrics\n\ + \# prometheus_interval: 60\n\ \\n\ \[AUTH]\n\ \# Set new_files option to off to completely prohibit uploading new files.\n\ @@ -188,6 +191,8 @@ xftpServerCLI cfgPath logPath = do logStatsStartTime = 0, -- seconds from 00:00 UTC serverStatsLogFile = combine logPath "file-server-stats.daily.log", serverStatsBackupFile = logStats $> combine logPath "file-server-stats.log", + prometheusInterval = eitherToMaybe $ read . T.unpack <$> lookupValue "STORE_LOG" "prometheus_interval" ini, + prometheusMetricsFile = combine logPath "xftp-server-metrics.txt", transportConfig = mkTransportServerConfig (fromMaybe False $ iniOnOff "TRANSPORT" "log_tls_errors" ini) diff --git a/src/Simplex/FileTransfer/Server/Prometheus.hs b/src/Simplex/FileTransfer/Server/Prometheus.hs new file mode 100644 index 000000000..b540807aa --- /dev/null +++ b/src/Simplex/FileTransfer/Server/Prometheus.hs @@ -0,0 +1,134 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-unrecognised-pragmas #-} + +module Simplex.FileTransfer.Server.Prometheus where + +import Data.Int (Int64) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Time.Clock (UTCTime (..), diffUTCTime) +import Data.Time.Clock.System (systemEpochDay) +import Data.Time.Format.ISO8601 (iso8601Show) +import Simplex.FileTransfer.Server.Stats +import Simplex.Messaging.Server.Stats (PeriodStatCounts (..)) +import Simplex.Messaging.Transport (simplexMQVersion) +import Simplex.Messaging.Util (tshow) + +data FileServerMetrics = FileServerMetrics + { statsData :: FileServerStatsData, + filesDownloadedPeriods :: PeriodStatCounts, + rtsOptions :: Text + } + +rtsOptionsEnv :: Text +rtsOptionsEnv = "XFTP_RTS_OPTIONS" + +{-# FOURMOLU_DISABLE\n#-} +xftpPrometheusMetrics :: FileServerMetrics -> UTCTime -> Text +xftpPrometheusMetrics sm ts = + time <> files <> info + where + FileServerMetrics {statsData, filesDownloadedPeriods, rtsOptions} = sm + FileServerStatsData + { _fromTime, + _filesCreated, + _fileRecipients, + _filesUploaded, + _filesExpired, + _filesDeleted, + _filesBlocked, + _fileDownloads, + _fileDownloadAcks, + _filesCount, + _filesSize + } = statsData + time = + "# Recorded at: " <> T.pack (iso8601Show ts) <> "\n\ + \# Stats from: " <> T.pack (iso8601Show _fromTime) <> "\n\ + \\n" + files = + "# Files\n\ + \# -----\n\ + \\n\ + \# HELP simplex_xftp_files_created Created files\n\ + \# TYPE simplex_xftp_files_created counter\n\ + \simplex_xftp_files_created " <> mshow _filesCreated <> "\n\ + \# filesCreated\n\ + \\n\ + \# HELP simplex_xftp_files_recipients Files recipients\n\ + \# TYPE simplex_xftp_files_recipients counter\n\ + \simplex_xftp_files_recipients " <> mshow _fileRecipients <> "\n\ + \# fileRecipients\n\ + \\n\ + \# HELP simplex_xftp_files_uploaded Uploaded files\n\ + \# TYPE simplex_xftp_files_uploaded counter\n\ + \simplex_xftp_files_uploaded " <> mshow _filesUploaded <> "\n\ + \# filesUploaded\n\ + \\n\ + \# HELP simplex_xftp_files_expired Expired files\n\ + \# TYPE simplex_xftp_files_expired counter\n\ + \simplex_xftp_files_expired " <> mshow _filesExpired <> "\n\ + \# filesExpired\n\ + \\n\ + \# HELP simplex_xftp_files_deleted Deleted files\n\ + \# TYPE simplex_xftp_files_deleted counter\n\ + \simplex_xftp_files_deleted " <> mshow _filesDeleted <> "\n\ + \# filesDeleted\n\ + \\n\ + \# HELP simplex_xftp_files_blocked Blocked files\n\ + \# TYPE simplex_xftp_files_blocked counter\n\ + \simplex_xftp_files_blocked " <> mshow _filesBlocked <> "\n\ + \# filesBlocked\n\ + \\n\ + \# HELP simplex_xftp_file_downloads File downloads\n\ + \# TYPE simplex_xftp_file_downloads counter\n\ + \simplex_xftp_file_downloads " <> mshow _fileDownloads <> "\n\ + \# fileDownloads\n\ + \\n\ + \# HELP simplex_xftp_file_download_acks File download ACKs\n\ + \# TYPE simplex_xftp_file_download_acks counter\n\ + \simplex_xftp_file_download_acks " <> mshow _fileDownloadAcks <> "\n\ + \# fileDownloadAcks\n\ + \\n\ + \# HELP simplex_xftp_files_count_total Total files count \n\ + \# TYPE simplex_xftp_files_count_total gauge\n\ + \simplex_xftp_files_count_total " <> mshow _filesCount <> "\n\ + \# filesCount\n\ + \\n\ + \# HELP simplex_xftp_files_size Size of files \n\ + \# TYPE simplex_xftp_files_size gauge\n\ + \simplex_xftp_files_size " <> mshow _filesSize <> "\n\ + \# filesSize \n\ + \\n\ + \# HELP simplex_xftp_files_count_daily Daily files count\n\ + \# TYPE simplex_xftp_files_count_daily gauge\n\ + \simplex_xftp_files_count_daily " <> mstr (dayCount filesDownloadedPeriods) <> "\n\ + \# filesDownloaded.dayCount\n\ + \\n\ + \# HELP simplex_xftp_files_count_weekly Weekly files count\n\ + \# TYPE simplex_xftp_files_count_weekly gauge\n\ + \simplex_xftp_files_count_weekly " <> mstr (weekCount filesDownloadedPeriods) <> "\n\ + \# filesDownloaded.weekCount\n\ + \\n\ + \# HELP simplex_xftp_files_count_monthly Monthly files count\n\ + \# TYPE simplex_xftp_files_count_monthly gauge\n\ + \simplex_xftp_files_count_monthly " <> mstr (monthCount filesDownloadedPeriods) <> "\n\ + \# filesDownloaded.monthCount\n\ + \\n" + info = + "# Info\n\ + \# ----\n\ + \\n\ + \# HELP simplex_xftp_info Server information. RTS options have to be passed via " <> rtsOptionsEnv <> " env var\n\ + \# TYPE simplex_xftp_info gauge\n\ + \simplex_xftp_info{version=\"" <> T.pack simplexMQVersion <> "\",rts_options=\"" <> rtsOptions <> "\"} 1\n\ + \\n" + mstr a = a <> " " <> tsEpoch + mshow :: Show a => a -> Text + mshow = mstr . tshow + tsEpoch = tshow @Int64 $ floor @Double $ realToFrac (ts `diffUTCTime` epoch) * 1000 + epoch = UTCTime systemEpochDay 0 +{-# FOURMOLU_ENABLE\n#-} diff --git a/src/Simplex/Messaging/Notifications/Server/Main.hs b/src/Simplex/Messaging/Notifications/Server/Main.hs index e5b085a7c..f79ad12b7 100644 --- a/src/Simplex/Messaging/Notifications/Server/Main.hs +++ b/src/Simplex/Messaging/Notifications/Server/Main.hs @@ -167,6 +167,9 @@ ntfServerCLI cfgPath logPath = <> "Time to retain deleted entities in the database, days.\n" <> ("# db_deleted_ttl: " <> tshow defaultDeletedTTL <> "\n\n") <> "log_stats: off\n\n\ + \# Log interval for real-time Prometheus metrics\n\ + \# prometheus_interval: 60\n\ + \\n\ \[AUTH]\n\ \# control_port_admin_password:\n\ \# control_port_user_password:\n\ diff --git a/src/Simplex/Messaging/Server/Main/Init.hs b/src/Simplex/Messaging/Server/Main/Init.hs index 9f52d0797..2823df74f 100644 --- a/src/Simplex/Messaging/Server/Main/Init.hs +++ b/src/Simplex/Messaging/Server/Main/Init.hs @@ -92,7 +92,7 @@ iniFileContent cfgPath logPath opts host basicAuth controlPortPwds = <> "# Log daily server statistics to CSV file\n" <> ("log_stats: " <> onOff logStats <> "\n\n") <> "# Log interval for real-time Prometheus metrics\n\ - \# prometheus_interval: 300\n\n\ + \# prometheus_interval: 60\n\n\ \[AUTH]\n\ \# Set new_queues option to off to completely prohibit creating new messaging queues.\n\ \# This can be useful when you want to decommission the server, but not all connections are switched yet.\n\ diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index d3215ea95..0f7310073 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -99,6 +99,9 @@ testXFTPLogFile = "tests/tmp/xftp-server-store.log" testXFTPStatsBackupFile :: FilePath testXFTPStatsBackupFile = "tests/tmp/xftp-server-stats.log" +xftpTestPrometheusMetricsFile :: FilePath +xftpTestPrometheusMetricsFile = "tests/tmp/xftp-server-metrics.txt" + testXFTPServerConfig :: XFTPServerConfig testXFTPServerConfig = XFTPServerConfig @@ -127,6 +130,8 @@ testXFTPServerConfig = logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", serverStatsBackupFile = Nothing, + prometheusInterval = Nothing, + prometheusMetricsFile = xftpTestPrometheusMetricsFile, transportConfig = mkTransportServerConfig True (Just alpnSupportedXFTPhandshakes) False, responseDelay = 0 } From b167d01f8ae10bb4132b783075ccdb6aa4ffbf3c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 30 Jul 2025 20:54:03 +0100 Subject: [PATCH 08/13] rfc: using the same profile from multiple devices (#1588) --- rfcs/2025-07-15-multi-device.md | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 rfcs/2025-07-15-multi-device.md diff --git a/rfcs/2025-07-15-multi-device.md b/rfcs/2025-07-15-multi-device.md new file mode 100644 index 000000000..56a09fea2 --- /dev/null +++ b/rfcs/2025-07-15-multi-device.md @@ -0,0 +1,104 @@ +# Using the same profile from multiple devices + +## Problem + +Double Ratchet algorithm makes it hard to send/receive messages sent to the user from different devices, as each message changes the state of Double Ratchet keys, and these state changes must be strictly sequential and they cannot be reversed (although skipping is possible). + +Traditional approach for multi-device converts each direct conversation into a group, where each device participates as a member. Likewise, for group conversations each device also participates as a member. While these members *look* as if they are the same user to others, a very simple client app modification may show device ID for each message, and the communication peers, both in direct chats and in groups would know how many devices a user has and which device the user sent the message from. In addition to that, with this approach communication peers can send different messages to different devices (it can be prevented by provider who would request that only message key is encrypted with DR, while the encrypted message is the same) or withheld from some devices (it cannot be prevented by provider, as it cannot add key to the communication in case it is missing, and cannot withhold the message completely too). These opens various vectors for targeted attacks, e.g.: +- tracking movements of the user: once each devices is identified as "desk" and "phone" it would allow to know where the user is at a given time. +- manipulating information by sending messages to one device (to have proof it was sent) and withholding from others, or sending different messages if the protocol allows it. + +In addition to that, the specific implementation of this approach in Signal compromises break-in recovery property (aka post-compromise security) of Double-Ratchet algorithm, making its design ineffective - the only reason to have the second ratchet in DR algorithm is to provide break-in recovery, without it a much simpler design with a single ratchet is sufficient. See [this paper](https://eprint.iacr.org/2021/626.pdf) for details. + +While this limitation can be addressed with notifications when a new device is added and per-device keys, we still find the remaining attack vectors on user security and privacy to be unacceptable, and opening unsuspecting users to various criminal actions - and it is wrong to say that would only affect security conscious users, and most people would not be affected by these risks. Allowing potential criminals in groups to know which device you are currently using is a real risk for all users. + +Another approach was offered by Threema that is ["mediator" server](https://threema.com/en/blog/md-architectural-overview) where the state of encryption ratchets is stored server-side. While it protects the user from their communication peers, it increases required level of trust to the servers, and in case of SimpleX network it would expose the knowledge of who communicates to whom. So while the idea of server-side storage of encryption state is promising, it has to be per-connection, to retain "no-accounts" property of SimpleX messaging network. + +Also see [FAQ](https://simplex.chat/faq/#why-cant-i-use-the-same-profile-on-different-devices) and [this issue](https://github.com/simplex-chat/simplex-chat/issues/444#issuecomment-3066968358). + +## Proposed solution + +One of the ideas presented in FAQ - to store the state of Double Ratchet algorithm in the encrypted container on the server seems promising. The RFC develops this idea. + +### Considerations for the design + +1. The largest ratchet state size with the current implementation is less than 8kb (which is achieved when both sides shared PQ keys and ciphertexts), so while it cannot fit in the same transport blocks together with sent and received messages, it would fit in one transport block. + +2. Protocol commands and events may be changed (even if at the cost of slightly reducing message size) can fit the hash of the ratchet state (32 bytes sha256 would be sufficient), so that the client can determine whether it has the most recent ratchet state or if it needs to retrieve the latest copy. Message size reduction won't affect the users because we use compression, and there is a substantial reserve. + +3. Client commands that modify ratchet state would include the hash of the previous ratchet state so that the server can reject or ignore the command in case the previous ratchet state is different or in case command is repeated in case of lost response). + +4. The client does not need to retrieve message state for each encryption and decryption operation - it can "speculatively" use the ratchet state it has, and receive correct ratchet state in the "error" response after attempting encryption based on incorrect ratchet state. + +## Proposed protocol design + +Ratchet state will be stored on the same server that stores message queue, as part of message queue record. 8kb is a sufficient size for this blob (the actual max size is 7800 bytes). The server would also store the hashes of the current and, possibly, the previous ratchet states (TBC). + +While ratchet is used for duplex connection, the connection still has primary queue, and with redundancy the same ratchet state can be stored on all secondary queues. + +Ratchet state will be encrypted using secret_box - a symmetric encryption scheme, so PQ-resistant. If ratchet state is stored on more than one server, it has to be encrypted with a different key for each server. + +Questions: how to rotate the key used to store ratchet? Should key used to encrypt ratchet rotate at the same time when queue is rotated? The latter is a logical option, as it prevents additional complexity and solves the problem anyway. A possible option is to have "ratchet version" that will be used to advance the key used to encrypt ratchet via HKDF. + +Security considerations: the scheme may reduce break-in recovery to the points queues are rotated, unless there is some randomness mixed-in into the key derivation (the key used to encrypt ratchet state). But including randomness would defeat the purpose, as other devices wouldn't be able to access the ratchets. Another approach would be to have each device use its own key for encryption, and encrypt to all keys of all devices (or to encrypt key, to avoid size increase). Having multiple encryptions would show how many devices use the queue, but servers already can observe it, so it is a better tradeoff. Another idea would be to rotate the key used to authorize queue commands - we already support multiple recipient keys, and it can be used for multi-device scenario. That would partially mitigate break-in attacks as the attacker who obtained the key from ratchet state would be able to decrypt it, but won't be able to decrypt it (the attacker collusion with the server is not mitigated). Yet another idea would be for each party (device) to share its private (or encapsulation) key and to have a symmetric key (used to encrypt the ratchet state) encrypted (encapsulated) separately for each device. This would reduce the size of the stored data to `ratchet size` + `encrypted key size` * N, so even in case of PQ encryption (e.g. sntrup) the size required to store the ratchet would be under transport block size, while limiting it to say 4-8 devices, which is sufficient. + +To participate in multi-device scheme the devices would join the usual group that will be used to share public (encapsulation) device keys and to communicate updates to conversations that were received by the currently "active" device. "Active" means the device that received or sent and processed the message, and while only one device can receive messages from a given queue, device "active" state may be determined per queue, allowing concurrent usage. + +The scheme must be resilient to state updates being lost, and in case of direct messages it would result in some messages not being shown (or shown as skipped), while conversation preference and profile updates can be re-requested from peers, while the current profile of the user would become the latest. Likewise, for groups state updates ca be requested from super-peers or for decentralized groups - from owners. Maintaining chat state consistency is an important consideration, but is not a focus of this RFC - the focus is managing message delivery and DR encryption for multiple devices. Other multi-device schemes have the same issues with state consistency. Partially, the profile state consistency can be improved by using a single shared queue (or set of queues) to store user's profile and chat preferences to synchronize profile updates asynchronously between the devices. + +## The protocol to send the message + +`rsi` - ratchet state on device `i`. + +`enc(rs)` - current authoritative ratchet state on the server. + +`pt` and `ct` - plaintext and ciphertext messages. + +Encryption is a state transition function ratchetEnc: `(ct, rs') = ratchetEnc(pt, rs)`. + +1. Device encrypts the message using the stored ratchet state: `(ct, rsi') = ratchetEnc(pt, rsi)` + +2. Device sends modified encrypted ratchet state and the hash of the previous encrypted state to the server that stores the queue: `RSET (hash(enc(rsi)), enc(rsi'))`. + +3. If the hash of the previous state matches state stored on the server (`hash(enc(rsi)) == hash(enc(rs))`), the server updates the state and responds with `ratchet_ok` (that may include the current state or it's hash, for validation). If the hash is different, the server responds with `bad_ratchet(enc(rs))` message that includes the correct ratchet state. These updates must be atomic. In this case device has to update the local ratchet state (provided it can decrypt it), and repeat encryption attempt. If device cannot decrypt the provided ratchet state, it means that the connection is disrupted (possibly, device is removed from device group, but missed the notifications). + +4. After successful state update in primary receiving queue, the device would update it in secondary receiving queues. + +5. Device sends encrypted message as usual, via proxy that must be different both from the server that stores the ratchet and from the destination server. + +6. Device broadcasts sent message and new ratchet state to other devices in the device group. + +This protocol is simple, and it minimizes requests when sending the message to one additional request to update ratchet state in most cases, only requiring two requests when device state was not updated via device group prior to message sending attempt. + +## The protocol to receive the message + +Decryption is also a state transition function: `(pt, rs') = ratchetDec(ct, rs)` + +1. Server sends the message to the device (can be in response to SUB or ACK commands, or with active subscription). Pushed message would include the hash of the currently stored ratchet state: `hash(enc(rs))`. + +2. If device has the ratchet state with the same hash (`hash(enc(rs)) == hash(enc(rsi))`), it decrypts the message: `(pt, rsi') = ratchetDec(ct, rsi)`. + +3. If device has ratchet state with a different hash, it requests ratchet from the server with additional protocol command `RGET` with response `RCHT (enc(rs))` and updates the local state. + +4. Device decrypts the message `(pt, rsi') = ratchetDec(ct, rsi)` and processes it as usual. + +5. Device sends acknowledgement to the server as usual, but now it includes the new ratchet state and the hash of the previous state: `ACK msgId (hash(enc(rsi)), enc(rsi'))` + +6. The server compares ratchet state with stored state hash, and in case it matches it processes `ACK` and responds with `OK` as usual (or `NO_MSG` in case msgId is incorrect, also as usual - it would happen in repeated ACK requests). If ratchet state hash does not match, the server would respond with `bad_ratchet(enc(rs))` - which means that the message was already processed by another device and ratchet was advanced. This is a complex scenario, as the client has to either revert the change from message processing or somehow combine the change with the updates communicated via device group (as a side note, device group can simply re-broadcast messages, not state updates, but it will result in state divergence between devices when different messages are lost). + +Unlike sending messages, this flow does not require any additional requests in most cases, only requiring requesting message state reconciliation when the same message was received and processed by more than one client, but it does not require re-acknowledgement. + +## Challenges + +This is an idea of the design rather than the actual design, as it requires more thinking about: +- how to handle concurrent ratchet state updates, +- "active" status transitions per queue, +- avoiding concurrent subscriptions to queues from multiple devices, +- state updates and synchronization between devices, +- handling skipped messages, +- costs to update ratchets in bulk send scenario - this scheme would substantially increase costs of preparing large broadcasts, and it makes this scheme not acceptable for chat relays. Which means that "profile" on desktop used as chat relay won't be synched to other devices. +- etc. + +## Advantages + +The communication peers won't know how many devices the user has, and which device was used to send the message. Also, the communication peers won't be able to send different messages to different user's devices, or to withhold messages from some devices. From db325cb81f77652471a27f4331143982739f9f10 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 31 Jul 2025 09:20:57 +0100 Subject: [PATCH 09/13] 6.4.2.0 --- simplexmq.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplexmq.cabal b/simplexmq.cabal index f57d433f8..d8a6f021c 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -1,7 +1,7 @@ cabal-version: 1.12 name: simplexmq -version: 6.4.1.0 +version: 6.4.2.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and From 9f263e8f3efb7b111e9736ae6eb3bb68a5b8a1f6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 2 Aug 2025 09:21:44 +0100 Subject: [PATCH 10/13] docs: changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a0a23a3..3224da07a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 6.4.2 + +SMP server: +- fix memory leak when connection interrupts straight after client connects. +- do not include repeated queue blocking into stats/quota. + +XFTP server: +- prometheus metrics + # 6.4.1 SMP protocol: From fb477b24d769b40c0d4726c90bc4117106c6eb06 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 5 Aug 2025 08:18:39 +0100 Subject: [PATCH 11/13] smp server: support short link URI as queue identifier in control port commands (#1596) --- src/Simplex/Messaging/Server.hs | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 60c05a7e8..a4686976d 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -963,24 +963,24 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt SubPending -> (c1, c2 + 1, c3, c4) SubThread _ -> (c1, c2, c3 + 1, c4) ProhibitSub -> pure (c1, c2, c3, c4 + 1) - CPDelete sId -> withAdminRole $ unliftIO u $ do + CPDelete qId -> withAdminRole $ unliftIO u $ do st <- asks msgStore r <- liftIO $ runExceptT $ do - q <- ExceptT $ getQueue st SSender sId + (q, _) <- ExceptT $ getSenderQueue st qId ExceptT $ deleteQueueSize st q case r of Left e -> liftIO $ hPutStrLn h $ "error: " <> show e Right (qr, numDeleted) -> do updateDeletedStats qr liftIO $ hPutStrLn h $ "ok, " <> show numDeleted <> " messages deleted" - CPStatus sId -> withUserRole $ unliftIO u $ do + CPStatus qId -> withUserRole $ unliftIO u $ do st <- asks msgStore - q <- liftIO $ getQueueRec st SSender sId + q <- liftIO $ getSenderQueue st qId liftIO $ hPutStrLn h $ case q of Left e -> "error: " <> show e Right (_, QueueRec {queueMode, status, updatedAt}) -> "status: " <> show status <> ", updatedAt: " <> show updatedAt <> ", queueMode: " <> show queueMode - CPBlock sId info -> withUserRole $ unliftIO u $ do + CPBlock qId info -> withUserRole $ unliftIO u $ do st <- asks msgStore stats <- asks serverStats blocked <- liftIO $ readIORef $ qBlocked stats @@ -989,7 +989,7 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt then liftIO $ hPutStrLn h $ "error: reached limit of " <> show quota <> " queues blocked daily" else do r <- liftIO $ runExceptT $ do - (q, QueueRec {status}) <- ExceptT $ getQueueRec st SSender sId + (q, QueueRec {status}) <- ExceptT $ getSenderQueue st qId when (status == EntityActive) $ ExceptT $ blockQueue (queueStore st) q info pure status case r of @@ -998,14 +998,18 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt incStat $ qBlocked stats liftIO $ hPutStrLn h "ok, queue blocked" Right status -> liftIO $ hPutStrLn h $ "ok, already inactive: " <> show status - CPUnblock sId -> withUserRole $ unliftIO u $ do + CPUnblock qId -> withUserRole $ unliftIO u $ do st <- asks msgStore r <- liftIO $ runExceptT $ do - q <- ExceptT $ getQueue st SSender sId - ExceptT $ unblockQueue (queueStore st) q + (q, QueueRec {status}) <- ExceptT $ getSenderQueue st qId + case status of + EntityBlocked info -> Right info <$ ExceptT (unblockQueue (queueStore st) q) + EntityActive -> pure $ Left True + EntityOff -> pure $ Left False liftIO $ hPutStrLn h $ case r of Left e -> "error: " <> show e - Right () -> "ok, queue unblocked" + Right (Right info) -> "ok, queue unblocked, reason to block was: " <> show info + Right (Left unblocked) -> if unblocked then "ok, queue was active" else "error, queue is inactive" CPSave -> withAdminRole $ withLock' (savingLock srv) "control" $ do hPutStrLn h "saving server state..." unliftIO u $ saveServer False @@ -1014,6 +1018,11 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt CPQuit -> pure () CPSkip -> pure () where + getSenderQueue st qId = + getQueueRec st SSender qId >>= \case + Right r -> pure $ Right r + Left AUTH -> getQueueRec st SSenderLink qId + Left e -> pure $ Left e withUserRole action = readTVarIO role >>= \case CPRAdmin -> action CPRUser -> action @@ -1508,8 +1517,8 @@ client rcvId <- randId ntf <- forM ntfKeys_ $ \(notifierKey, rcvNtfDhSecret, rcvPubDhKey) -> do notifierId <- randId - let ntfCreds = NtfCreds {notifierId, notifierKey, rcvNtfDhSecret, ntfServiceId = Nothing} - pure (ntfCreds, ServerNtfCreds notifierId rcvPubDhKey) + let ntfCreds' = NtfCreds {notifierId, notifierKey, rcvNtfDhSecret, ntfServiceId = Nothing} + pure (ntfCreds', ServerNtfCreds notifierId rcvPubDhKey) let queueMode = queueReqMode <$> queueReqData qr = QueueRec From 79ba60e3ad415f07568151f6b7756f721212502c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 5 Aug 2025 12:29:50 +0100 Subject: [PATCH 12/13] agent: update contact request server hosts to match server in short link (#1597) --- src/Simplex/Messaging/Agent.hs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 4830e6dfe..60ea5d69c 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -912,12 +912,19 @@ getConnShortLink' c nm userId = \case where decryptData :: ConnectionModeI c => SMPServer -> LinkKey -> C.SbKey -> (SMP.SenderId, QueueLinkData) -> AM (ConnectionRequestUri c, ConnLinkData c) decryptData srv linkKey k (sndId, d) = do - r@(cReq, _) <- liftEither $ SL.decryptLinkData @c linkKey k d + r@(cReq, clData) <- liftEither $ SL.decryptLinkData @c linkKey k d let (srv', sndId') = qAddress (connReqQueue cReq) unless (srv `sameSrvHost` srv' && sndId == sndId') $ throwE $ AGENT $ A_LINK "different address" - pure r + pure $ if srv' == srv then r else (updateConnReqServer srv cReq, clData) sameSrvHost ProtocolServer {host = h :| _} ProtocolServer {host = hs} = h `elem` hs + updateConnReqServer :: SMPServer -> ConnectionRequestUri c -> ConnectionRequestUri c + updateConnReqServer srv = \case + CRInvitationUri crData e2eParams -> CRInvitationUri (updateQueues crData) e2eParams + CRContactUri crData -> CRContactUri $ updateQueues crData + where + updateQueues crData@(ConnReqUriData {crSmpQueues = SMPQueueUri vr addr :| qs}) = + crData {crSmpQueues = SMPQueueUri vr addr {smpServer = srv} :| qs} deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId From 931c533a3ddb86345e95ac54e24df5474d9a349b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 5 Aug 2025 12:30:28 +0100 Subject: [PATCH 13/13] 6.4.3.0 --- simplexmq.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplexmq.cabal b/simplexmq.cabal index d8a6f021c..5f3d96304 100644 --- a/simplexmq.cabal +++ b/simplexmq.cabal @@ -1,7 +1,7 @@ cabal-version: 1.12 name: simplexmq -version: 6.4.2.0 +version: 6.4.3.0 synopsis: SimpleXMQ message broker description: This package includes <./docs/Simplex-Messaging-Server.html server>, <./docs/Simplex-Messaging-Client.html client> and