mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-09-24 06:55:18 +00:00
* xftp: implementation of XFTP client as web page (rfc, low level functions) * protocol, file descriptions, more cryptogrpahy, handshake encoding, etc. * xftp server changes to support web slients: SNI-based certificate choice, CORS headers, OPTIONS request * web handshake * test for xftp web handshake * xftp-web client functions, fix transmission encoding * support description "redirect" in agent.ts and cross-platform compatibility tests (Haskell <> TypeScript) * rfc: web transport * client transport abstraction * browser environment * persistent client sessions * move rfcs * web page plan * improve plan * webpage implementation (not tested) * fix test * fix test 2 * fix test 3 * fixes and page test plan * allow sending xftp client hello after handshake - for web clients that dont know if established connection exists * page tests pass * concurrent and padded hellos in the server * update TS client to pad hellos * fix tests * preview:local * local preview over https * fixed https in the test page * web test cert fixtures * debug logging in web page and server * remove debug logging in server/browser, run preview xftp server via cabal run to ensure the latest code is used * debug logging for page sessions * add plan * improve error handling, handle browser reconnections/re-handshake * fix * debugging * opfs fallback * delete test screenshot * xftp CLI to support link * fix encoding for XFTPServerHandshake * support redirect file descriptions in xftp CLI receive * refactor CLI redirect * xftp-web: fixes and multi-server upload (#1714) * fix: await sodium.ready in crypto/keys.ts (+ digest.ts StateAddress cast) * multi-server parallel upload, remove pickRandomServer * fix worker message race: wait for ready signal before posting messages * suppress vite build warnings: emptyOutDir, externals, chunkSizeWarningLimit * fix Haskell web tests: use agent+server API, wrap server in array, suppress debug logs * remove dead APIs: un-export connectXFTP, delete closeXFTP * fix TypeScript errors in check:web (#1716) - client.ts: cast globalThis.process to any for browser tsconfig, suppress node:http2 import, use any for Buffer/chunks, cast fetch body - crypto.worker.ts: cast sha512_init() return to StateAddress * fix: serialize worker message processing to prevent OPFS handle race async onmessage allows interleaved execution at await points. When downloadFileRaw fetches chunks from multiple servers in parallel, concurrent handleDecryptAndStore calls both see downloadWriteHandle as null and race on createSyncAccessHandle for the same file, causing intermittent NoModificationAllowedError. Chain message handlers on a promise queue so each runs to completion before the next starts. * xftp-web: prepare for npm publishing (#1715) * prepare package.json for npm publishing Remove private flag, add description/license/repository/publishConfig, rename postinstall to pretest, add prepublishOnly, set files and main. * stable output filenames in production build * fix repository url format, expand files array * embeddable component: scoped CSS, dark mode, i18n, events, share - worker output to assets/ for single-directory deployment - scoped all CSS under #app, removed global resets - dark mode via .dark ancestor class - progress ring reads colors from CSS custom properties - i18n via window.__XFTP_I18N__ with t() helper - configurable mount element via data-xftp-app attribute - optional hashchange listener (data-no-hashchange) - completion events: xftp:upload-complete, xftp:download-complete - enhanced file-too-large error mentioning SimpleX app - native share button via navigator.share * deferred init and runtime server configuration - data-defer-init attribute skips auto-initialization - window.__XFTP_SERVERS__ overrides baked-in server list * use relative base path for relocatable build output * xftp-web: retry resets to default state, use innerHTML for errors * xftp-web: only enter download mode for valid XFTP URIs in hash * xftp-web: render UI before WASM is ready Move sodium.ready await after UI initialization so the upload/download interface appears instantly. WASM is only needed when user triggers an actual upload or download. Dispatch xftp:ready event once WASM loads. * xftp-web: CLS placeholder HTML and embedder CSS selectors Add placeholder HTML to index.html so the page renders a styled card before JS executes, preventing layout shift. Use a <template> element with an inline script to swap to the download placeholder when the URL hash indicates a file download. Auto-compute CSP SHA-256 hashes for inline scripts in the vite build plugin. Change all CSS selectors from #app to :is(#app, [data-xftp-app]) so styles apply when the widget is embedded with data-xftp-app attribute. * xftp-web: progress ring overhaul Rewrite progress ring with smooth lerp animation, green checkmark on completion, theme reactivity via MutationObserver, and per-phase color variables (encrypt/upload/download/decrypt). Show honest per-phase progress: each phase animates 0-100% independently with a ring color change between phases. Add decrypt progress callback from the web worker so the decryption phase tracks real chunk processing instead of showing an indeterminate spinner. Snap immediately on phase reset (0) and completion (1) to avoid lingering partial progress. Clean up animation and observers via destroy() in finally blocks. * xftp-web: single progress ring for upload, simplify ring color * xftp-web: single progress ring for download * feat(xftp-web): granular progress for encrypt/decrypt phases Add byte-level progress callbacks to encryptFile, decryptChunks, and sha512Streaming by processing data in 256KB segments. Worker reports fine-grained progress across all phases (encrypt+hash+write for upload, read+hash+decrypt for download). Progress ring gains fillTo method for smooth ease-out animation during minimum display delays. Encrypt/decrypt phases fill their weighted regions (0-15% and 85-99%) with real callbacks, with fillTo covering remaining time when work finishes under the 1s minimum for files >= 100KB. * rename package --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum <github.shum@liber.li> Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
312 lines
14 KiB
Haskell
312 lines
14 KiB
Haskell
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE MultiWayIf #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
module Simplex.Messaging.Transport.Server
|
|
( TransportServerConfig (..),
|
|
ServerCredentials (..),
|
|
TLSServerCredential (..),
|
|
SNICredentialUsed,
|
|
AddHTTP,
|
|
mkTransportServerConfig,
|
|
runTransportServerState,
|
|
runTransportServerState_,
|
|
SocketState,
|
|
SocketStats (..),
|
|
newSocketState,
|
|
getSocketStats,
|
|
runTransportServer,
|
|
runTransportServerSocket,
|
|
runLocalTCPServer,
|
|
startTCPServer,
|
|
loadServerCredential,
|
|
loadFingerprint,
|
|
loadFileFingerprint,
|
|
smpServerHandshake,
|
|
)
|
|
where
|
|
|
|
import Control.Applicative ((<|>))
|
|
import Control.Logger.Simple
|
|
import Control.Monad
|
|
import qualified Crypto.Store.X509 as SX
|
|
import qualified Data.ByteString as B
|
|
import Data.Default (def)
|
|
import Data.IntMap.Strict (IntMap)
|
|
import qualified Data.IntMap.Strict as IM
|
|
import Data.List (find)
|
|
import Data.Maybe (fromJust, fromMaybe, maybeToList)
|
|
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.Transport.Shared
|
|
import Simplex.Messaging.Util (catchAll_, labelMyThread, tshow, unlessM)
|
|
import System.Exit (exitFailure)
|
|
import System.IO.Error (tryIOError)
|
|
import System.Mem.Weak (Weak, deRefWeak)
|
|
import UnliftIO (timeout)
|
|
import UnliftIO.Concurrent
|
|
import qualified UnliftIO.Exception as E
|
|
import UnliftIO.STM
|
|
|
|
data TransportServerConfig = TransportServerConfig
|
|
{ logTLSErrors :: Bool,
|
|
serverALPN :: Maybe [ALPN],
|
|
askClientCert :: Bool,
|
|
addCORSHeaders :: Bool,
|
|
tlsSetupTimeout :: Int,
|
|
transportTimeout :: Int
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data ServerCredentials = ServerCredentials
|
|
{ caCertificateFile :: Maybe FilePath, -- CA certificate private key is not needed for initialization
|
|
privateKeyFile :: FilePath,
|
|
certificateFile :: FilePath
|
|
}
|
|
deriving (Show)
|
|
|
|
type AddHTTP = Bool
|
|
|
|
data TLSServerCredential = TLSServerCredential
|
|
{ credential :: T.Credential,
|
|
-- `sniCredential` is used when SNI is sent by the client.
|
|
-- It is needed to provide different credential when the server is accessed from the browser.
|
|
sniCredential :: Maybe T.Credential
|
|
}
|
|
|
|
type SNICredentialUsed = Bool
|
|
|
|
mkTransportServerConfig :: Bool -> Maybe [ALPN] -> Bool -> TransportServerConfig
|
|
mkTransportServerConfig logTLSErrors serverALPN askClientCert =
|
|
TransportServerConfig
|
|
{ logTLSErrors,
|
|
serverALPN,
|
|
askClientCert,
|
|
addCORSHeaders = False,
|
|
tlsSetupTimeout = 60000000,
|
|
transportTimeout = 40000000
|
|
}
|
|
|
|
serverTransportConfig :: TransportServerConfig -> TransportConfig
|
|
serverTransportConfig TransportServerConfig {logTLSErrors} =
|
|
-- TransportConfig {logTLSErrors, transportTimeout = Just transportTimeout}
|
|
TransportConfig {logTLSErrors, transportTimeout = Nothing}
|
|
|
|
-- | Run transport server (plain TCP or WebSockets) on passed TCP port and signal when server started and stopped via passed TMVar.
|
|
--
|
|
-- All accepted connections are passed to the passed function.
|
|
runTransportServer :: Transport c => TMVar Bool -> ServiceName -> T.Supported -> T.Credential -> TransportServerConfig -> (c 'TServer -> IO ()) -> IO ()
|
|
runTransportServer started port srvSupported srvCreds cfg server = do
|
|
ss <- newSocketState
|
|
runTransportServerState ss started port srvSupported srvCreds cfg server
|
|
|
|
runTransportServerState :: Transport c => SocketState -> TMVar Bool -> ServiceName -> T.Supported -> T.Credential -> TransportServerConfig -> (c 'TServer -> IO ()) -> IO ()
|
|
runTransportServerState ss started port srvSupported credential cfg server = runTransportServerState_ ss started port srvSupported srvCreds cfg (\_ -> server . snd)
|
|
where
|
|
srvCreds = TLSServerCredential {credential, sniCredential = Nothing}
|
|
|
|
runTransportServerState_ :: forall c. Transport c => SocketState -> TMVar Bool -> ServiceName -> T.Supported -> TLSServerCredential -> TransportServerConfig -> (Socket -> (SNICredentialUsed, c 'TServer) -> IO ()) -> IO ()
|
|
runTransportServerState_ ss started port = runTransportServerSocketState ss started (startTCPServer started Nothing port) (transportName (TProxy :: TProxy c 'TServer))
|
|
|
|
-- | Run a transport server with provided connection setup and handler.
|
|
runTransportServerSocket :: Transport c => TMVar Bool -> IO Socket -> String -> T.ServerParams -> TransportServerConfig -> (c 'TServer -> IO ()) -> IO ()
|
|
runTransportServerSocket started getSocket threadLabel srvParams cfg server = do
|
|
ss <- newSocketState
|
|
runTransportServerSocketState_ ss started getSocket threadLabel (tlsSetupTimeout cfg) setupTLS (\_ -> server . snd)
|
|
where
|
|
tCfg = serverTransportConfig cfg
|
|
setupTLS conn = do
|
|
tls <- connectTLS Nothing tCfg srvParams conn
|
|
(False,) <$> getTransportConnection tCfg True (X.CertificateChain []) tls
|
|
|
|
runTransportServerSocketState :: Transport c => SocketState -> TMVar Bool -> IO Socket -> String -> T.Supported -> TLSServerCredential -> TransportServerConfig -> (Socket -> (SNICredentialUsed, c 'TServer) -> IO ()) -> IO ()
|
|
runTransportServerSocketState ss started getSocket threadLabel srvSupported srvCreds cfg server =
|
|
runTransportServerSocketState_ ss started getSocket threadLabel (tlsSetupTimeout cfg) setupTLS server
|
|
where
|
|
tCfg = serverTransportConfig cfg
|
|
setupTLS conn = do
|
|
sniUsed <- newTVarIO False
|
|
let srvParams = supportedTLSServerParams srvSupported srvCreds sniUsed $ serverALPN cfg
|
|
h <- setupTLS_ srvParams
|
|
sni <- readTVarIO sniUsed
|
|
pure (sni, h)
|
|
where
|
|
setupTLS_ srvParams
|
|
| askClientCert cfg = do
|
|
clientCert <- newEmptyTMVarIO
|
|
tls <- connectTLS Nothing tCfg (paramsAskClientCert clientCert srvParams) conn
|
|
chain <- takePeerCertChain clientCert `E.onException` closeTLS tls
|
|
getTransportConnection tCfg True chain tls
|
|
| otherwise = do
|
|
tls <- connectTLS Nothing tCfg srvParams conn
|
|
getTransportConnection tCfg True (X.CertificateChain []) tls
|
|
|
|
-- | Run a transport server with provided connection setup and handler.
|
|
runTransportServerSocketState_ :: Transport c => SocketState -> TMVar Bool -> IO Socket -> String -> Int -> (Socket -> IO (SNICredentialUsed, c 'TServer)) -> (Socket -> (SNICredentialUsed, c 'TServer) -> IO ()) -> IO ()
|
|
runTransportServerSocketState_ ss started getSocket threadLabel tlsSetupTimeout setupTLS server = do
|
|
labelMyThread $ "transport server for " <> threadLabel
|
|
runTCPServerSocket ss started getSocket $ \conn -> do
|
|
labelMyThread $ threadLabel <> "/setup"
|
|
E.bracket
|
|
(timeout tlsSetupTimeout (setupTLS conn) >>= maybe (fail "tls setup timeout") pure)
|
|
(closeConnection . snd)
|
|
(server conn)
|
|
|
|
-- | Run TCP server without TLS
|
|
runLocalTCPServer :: TMVar Bool -> ServiceName -> (Socket -> IO ()) -> IO ()
|
|
runLocalTCPServer started port server = do
|
|
ss <- newSocketState
|
|
runTCPServerSocket ss started (startTCPServer started (Just "127.0.0.1") port) server
|
|
|
|
-- | Wrap socket provider in a TCP server bracket.
|
|
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 (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 $ 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 $ 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.
|
|
--
|
|
-- 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 = [eCONNABORTED, 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)))
|
|
|
|
data SocketStats = SocketStats
|
|
{ socketsAccepted :: Int,
|
|
socketsClosed :: Int,
|
|
socketsActive :: Int,
|
|
socketsLeaked :: Int
|
|
}
|
|
|
|
newSocketState :: IO SocketState
|
|
newSocketState = (,,) <$> newTVarIO 0 <*> newTVarIO 0 <*> newTVarIO mempty
|
|
|
|
getSocketStats :: SocketState -> IO SocketStats
|
|
getSocketStats (accepted, closed, active) = do
|
|
socketsAccepted <- readTVarIO accepted
|
|
socketsClosed <- readTVarIO closed
|
|
socketsActive <- IM.size <$> readTVarIO active
|
|
let socketsLeaked = socketsAccepted - socketsClosed - socketsActive
|
|
pure SocketStats {socketsAccepted, socketsClosed, socketsActive, socketsLeaked}
|
|
|
|
closeServer :: TMVar Bool -> TVar (IntMap (Weak ThreadId)) -> Socket -> IO ()
|
|
closeServer started clients sock = do
|
|
close sock
|
|
readTVarIO clients >>= mapM_ (deRefWeak >=> mapM_ killThread)
|
|
void . atomically $ tryPutTMVar started False
|
|
|
|
startTCPServer :: TMVar Bool -> Maybe HostName -> ServiceName -> IO Socket
|
|
startTCPServer started host port = withSocketsDo $ resolve >>= open >>= setStarted
|
|
where
|
|
resolve =
|
|
let hints = defaultHints {addrFlags = [AI_PASSIVE], addrSocketType = Stream}
|
|
in select <$> getAddrInfo (Just hints) host (Just port)
|
|
select as = fromJust $ family AF_INET6 <|> family AF_INET
|
|
where
|
|
family f = find ((== f) . addrFamily) as
|
|
open addr = do
|
|
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
|
|
setSocketOption sock ReuseAddr 1
|
|
withFdSocket sock setCloseOnExecIfNeeded
|
|
logNote $ "binding to " <> tshow (addrAddress addr)
|
|
bind sock $ addrAddress addr
|
|
listen sock 1024
|
|
pure sock
|
|
setStarted sock = atomically (tryPutTMVar started True) >> pure sock
|
|
|
|
loadServerCredential :: ServerCredentials -> IO T.Credential
|
|
loadServerCredential ServerCredentials {caCertificateFile, certificateFile, privateKeyFile} =
|
|
T.credentialLoadX509Chain certificateFile (maybeToList caCertificateFile) privateKeyFile >>= \case
|
|
Right credential -> pure credential
|
|
Left _ -> putStrLn "invalid credential" >> exitFailure
|
|
|
|
supportedTLSServerParams :: T.Supported -> TLSServerCredential -> TVar SNICredentialUsed -> Maybe [ALPN] -> T.ServerParams
|
|
supportedTLSServerParams serverSupported TLSServerCredential {credential, sniCredential} sniCredUsed alpn_ =
|
|
def
|
|
{ T.serverWantClientCert = False,
|
|
T.serverHooks =
|
|
def
|
|
{ T.onServerNameIndication = case sniCredential of
|
|
Nothing -> \_ -> pure $ T.Credentials [credential]
|
|
Just sniCred -> \case
|
|
Nothing -> pure $ T.Credentials [credential]
|
|
Just _host -> T.Credentials [sniCred] <$ atomically (writeTVar sniCredUsed True),
|
|
T.onALPNClientSuggest = (\alpn -> pure . fromMaybe "" . find (`elem` alpn)) <$> alpn_
|
|
},
|
|
T.serverSupported = serverSupported
|
|
}
|
|
|
|
paramsAskClientCert :: TMVar (Maybe X.CertificateChain) -> T.ServerParams -> T.ServerParams
|
|
paramsAskClientCert clientCert params =
|
|
params
|
|
{ T.serverWantClientCert = True,
|
|
T.serverHooks =
|
|
(T.serverHooks params)
|
|
{ T.onClientCertificate = \cc ->
|
|
validateClientCertificate cc >>= \case
|
|
Just reason -> T.CertificateUsageReject reason <$ atomically (tryPutTMVar clientCert Nothing)
|
|
Nothing -> T.CertificateUsageAccept <$ atomically (tryPutTMVar clientCert $ Just cc)
|
|
}
|
|
}
|
|
|
|
validateClientCertificate :: X.CertificateChain -> IO (Maybe T.CertificateRejectReason)
|
|
validateClientCertificate cc = case chainIdCaCerts cc of
|
|
CCEmpty -> pure Nothing -- client certificates are only used for services
|
|
CCSelf cert -> validate cert
|
|
CCValid {caCert} -> validate caCert
|
|
CCLong -> pure $ Just $ T.CertificateRejectOther "chain too long"
|
|
where
|
|
validate caCert = usage <$> x509validate caCert ("", B.empty) cc
|
|
usage [] = Nothing
|
|
usage r =
|
|
Just $
|
|
if
|
|
| XV.Expired `elem` r || XV.InFuture `elem` r -> T.CertificateRejectExpired
|
|
| XV.UnknownCA `elem` r -> T.CertificateRejectUnknownCA
|
|
| otherwise -> T.CertificateRejectOther (show r)
|
|
|
|
loadFingerprint :: ServerCredentials -> IO Fingerprint
|
|
loadFingerprint ServerCredentials {caCertificateFile} = case caCertificateFile of
|
|
Just certificateFile -> loadFileFingerprint certificateFile
|
|
Nothing -> error "CA file must be used in protocol credentials"
|
|
|
|
loadFileFingerprint :: FilePath -> IO Fingerprint
|
|
loadFileFingerprint certificateFile = do
|
|
(cert : _) <- SX.readSignedObject certificateFile
|
|
pure $ XV.getFingerprint (cert :: X.SignedExact X.Certificate) X.HashSHA256
|