mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-04-27 15:05:13 +00:00
5241f5fe5e
* rfc: client certificates for high volume clients (opertors' chat relays, notification servers, service bots) * client certificates types (WIP) * parameterize Transport * protocol/schema/api changes * agent API * rename command * agent subscriptions return local ClientServiceId to chat * verify transmissions * fix receiving client certificates, refactor * ntf server: remove shared queue for all notification subscriptions (#1543) * ntf server: remove shared queue for all notification subscriptions * wait for subscriber with timeout * safer * refactor * log * remove unused * WIP service subscriptions and associations, refactor * process service subscriptions * rename * simplify switching subscriptions * SMP service handshake with additional server handshake response * notification delivery and STM persistence for services * smp server: database storage, store log, fix encoding for STORE error, replace String with Text in locks and error * stats * more stats * rename SMP commands * service subscriptions in ntf server agent (tests fail) * fix * refactor * exports * subscribe ntf server as service for associated queues * test ntf service connection, fix SOKS response, fix service associations not removed in STM storage * INI option to support services * ntf server: downgrade subscriptions when service is no longer supported, track counts of subscribed queues * smp protocol: include service certificate fingerprint in the string signed over with entity key (TODO two tests fail) * fix test * ntf server prometheus stats, use Int64 in SOKS/ENDS responses (to avoid conversions), additional error status for ntf subscription * update RFC * refactor useServiceAuth to avoid ad hoc decisions about which commands use service signatures, and to prohibit service signatures on other commands * remove duplicate service signature syntax check from checkCredentials, it is checked in verifyTransmission * service errors, todos * fix checkCredentials in ntf server, service errors * refactor service auth * refactor * service agent: store returned queue count instead of expected * refactor serverThread * refactor serviceSig * rename * refactor, rename, test repeat NSUB service association * respond with error to SUBS * smp server: export/import service records between database and store log * comment * comments * ghc 8.10.7
308 lines
12 KiB
Haskell
308 lines
12 KiB
Haskell
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE FlexibleInstances #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE NumericUnderscores #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
module Simplex.Messaging.Transport.Client
|
|
( runTransportClient,
|
|
runTLSTransportClient,
|
|
smpClientHandshake,
|
|
defaultSMPPort,
|
|
defaultTcpConnectTimeout,
|
|
defaultTransportClientConfig,
|
|
defaultSocksProxyWithAuth,
|
|
defaultSocksProxy,
|
|
defaultSocksHost,
|
|
TransportClientConfig (..),
|
|
SocksProxy (..),
|
|
SocksProxyWithAuth (..),
|
|
SocksAuth (..),
|
|
TransportHost (..),
|
|
TransportHosts (..),
|
|
TransportHosts_ (..),
|
|
validateCertificateChain,
|
|
)
|
|
where
|
|
|
|
import Control.Applicative (optional, (<|>))
|
|
import Control.Logger.Simple (logError)
|
|
import Data.Aeson (FromJSON (..), ToJSON (..))
|
|
import qualified Data.Attoparsec.ByteString.Char8 as A
|
|
import Data.ByteString.Char8 (ByteString)
|
|
import qualified Data.ByteString.Char8 as B
|
|
import Data.Char (isAsciiLower, isDigit, isHexDigit)
|
|
import Data.Default (def)
|
|
import Data.IORef
|
|
import Data.IP
|
|
import Data.List.NonEmpty (NonEmpty (..))
|
|
import qualified Data.List.NonEmpty as L
|
|
import Data.Maybe (fromMaybe)
|
|
import Data.String
|
|
import Data.Word (Word32, Word8)
|
|
import qualified Data.X509 as X
|
|
import qualified Data.X509.CertificateStore as XS
|
|
import Data.X509.Validation (Fingerprint (..))
|
|
import qualified Data.X509.Validation as XV
|
|
import GHC.IO.Exception (IOErrorType (..))
|
|
import Network.Socket
|
|
import Network.Socks5
|
|
import qualified Network.TLS as T
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Encoding
|
|
import Simplex.Messaging.Encoding.String
|
|
import Simplex.Messaging.Parsers (parseAll, parseString)
|
|
import Simplex.Messaging.Transport
|
|
import Simplex.Messaging.Transport.KeepAlive
|
|
import Simplex.Messaging.Transport.Shared
|
|
import Simplex.Messaging.Util (bshow, catchAll, tshow, (<$?>))
|
|
import System.IO.Error
|
|
import Text.Read (readMaybe)
|
|
import UnliftIO.Exception (IOException)
|
|
import qualified UnliftIO.Exception as E
|
|
import UnliftIO.STM
|
|
|
|
data TransportHost
|
|
= THIPv4 (Word8, Word8, Word8, Word8)
|
|
| THIPv6 (Word32, Word32, Word32, Word32)
|
|
| THOnionHost ByteString
|
|
| THDomainName HostName
|
|
deriving (Eq, Ord, Show)
|
|
|
|
instance Encoding TransportHost where
|
|
smpEncode = smpEncode . strEncode
|
|
smpP = parseAll strP <$?> smpP
|
|
|
|
instance StrEncoding TransportHost where
|
|
strEncode = \case
|
|
THIPv4 (a1, a2, a3, a4) -> B.intercalate "." $ map bshow [a1, a2, a3, a4]
|
|
THIPv6 addr -> bshow $ toIPv6w addr
|
|
THOnionHost host -> host
|
|
THDomainName host -> B.pack host
|
|
strP =
|
|
A.choice
|
|
[ THIPv4 <$> ((,,,) <$> ipNum <*> ipNum <*> ipNum <*> A.decimal),
|
|
maybe (Left "bad IPv6") (Right . THIPv6 . fromIPv6w) . readMaybe . B.unpack <$?> ipv6StrP,
|
|
THOnionHost <$> ((<>) <$> A.takeWhile (\c -> isAsciiLower c || isDigit c) <*> A.string ".onion"),
|
|
THDomainName . B.unpack <$> (notOnion <$?> A.takeWhile1 (A.notInClass ":#,;/ \n\r\t"))
|
|
]
|
|
where
|
|
ipNum = validIP <$?> (A.decimal <* A.char '.')
|
|
validIP :: Int -> Either String Word8
|
|
validIP n = if 0 <= n && n <= 255 then Right $ fromIntegral n else Left "invalid IP address"
|
|
ipv6StrP =
|
|
A.char '[' *> A.takeWhile1 (/= ']') <* A.char ']'
|
|
<|> A.takeWhile1 (\c -> isHexDigit c || c == ':')
|
|
notOnion s = if ".onion" `B.isSuffixOf` s then Left "invalid onion host" else Right s
|
|
|
|
instance ToJSON TransportHost where
|
|
toEncoding = strToJEncoding
|
|
toJSON = strToJSON
|
|
|
|
instance FromJSON TransportHost where
|
|
parseJSON = strParseJSON "TransportHost"
|
|
|
|
newtype TransportHosts = TransportHosts {thList :: NonEmpty TransportHost}
|
|
|
|
instance StrEncoding TransportHosts where
|
|
strEncode = strEncodeList . L.toList . thList
|
|
strP = TransportHosts . L.fromList <$> strP `A.sepBy1'` A.char ','
|
|
|
|
newtype TransportHosts_ = TransportHosts_ {thList_ :: [TransportHost]}
|
|
|
|
instance StrEncoding TransportHosts_ where
|
|
strEncode = strEncodeList . thList_
|
|
strP = TransportHosts_ <$> strP `A.sepBy'` A.char ','
|
|
|
|
instance IsString TransportHost where fromString = parseString strDecode
|
|
|
|
instance IsString (NonEmpty TransportHost) where fromString = parseString strDecode
|
|
|
|
data TransportClientConfig = TransportClientConfig
|
|
{ socksProxy :: Maybe SocksProxy,
|
|
tcpConnectTimeout :: Int,
|
|
tcpKeepAlive :: Maybe KeepAliveOpts,
|
|
logTLSErrors :: Bool,
|
|
clientCredentials :: Maybe T.Credential,
|
|
clientALPN :: Maybe [ALPN],
|
|
useSNI :: Bool
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- time to resolve host, connect socket, set up TLS
|
|
defaultTcpConnectTimeout :: Int
|
|
defaultTcpConnectTimeout = 25_000_000
|
|
|
|
defaultTransportClientConfig :: TransportClientConfig
|
|
defaultTransportClientConfig =
|
|
TransportClientConfig
|
|
{ socksProxy = Nothing,
|
|
tcpConnectTimeout = defaultTcpConnectTimeout,
|
|
tcpKeepAlive = Just defaultKeepAliveOpts,
|
|
logTLSErrors = True,
|
|
clientCredentials = Nothing,
|
|
clientALPN = Nothing,
|
|
useSNI = True
|
|
}
|
|
|
|
clientTransportConfig :: TransportClientConfig -> TransportConfig
|
|
clientTransportConfig TransportClientConfig {logTLSErrors} =
|
|
TransportConfig {logTLSErrors, transportTimeout = Nothing}
|
|
|
|
-- | Connect to passed TCP host:port and pass handle to the client.
|
|
runTransportClient :: Transport c => TransportClientConfig -> Maybe SocksCredentials -> TransportHost -> ServiceName -> Maybe C.KeyHash -> (c 'TClient -> IO a) -> IO a
|
|
runTransportClient = runTLSTransportClient defaultSupportedParams Nothing
|
|
|
|
runTLSTransportClient :: Transport c => T.Supported -> Maybe XS.CertificateStore -> TransportClientConfig -> Maybe SocksCredentials -> TransportHost -> ServiceName -> Maybe C.KeyHash -> (c 'TClient -> IO a) -> IO a
|
|
runTLSTransportClient tlsParams caStore_ cfg@TransportClientConfig {socksProxy, tcpKeepAlive, clientCredentials, clientALPN, useSNI} socksCreds host port keyHash client = do
|
|
serverCert <- newEmptyTMVarIO
|
|
clientCredsSent <- newIORef False
|
|
let hostName = B.unpack $ strEncode host
|
|
clientParams = mkTLSClientParams tlsParams caStore_ hostName port keyHash clientCredentials clientCredsSent clientALPN useSNI serverCert
|
|
connectTCP = case socksProxy of
|
|
Just proxy -> connectSocksClient proxy socksCreds (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
|
|
-- No TLS timeout to avoid failing connections via SOCKS
|
|
tls <- connectTLS (Just hostName) tCfg clientParams sock
|
|
chain <- takePeerCertChain serverCert `E.onException` closeTLS tls
|
|
sent <- readIORef clientCredsSent
|
|
getTransportConnection tCfg sent chain tls
|
|
client c `E.finally` closeConnection c
|
|
where
|
|
hostAddr = \case
|
|
THIPv4 addr -> SocksAddrIPV4 $ tupleToHostAddress addr
|
|
THIPv6 addr -> SocksAddrIPV6 addr
|
|
THOnionHost h -> SocksAddrDomainName h
|
|
THDomainName h -> SocksAddrDomainName $ B.pack h
|
|
|
|
connectTCPClient :: HostName -> ServiceName -> IO Socket
|
|
connectTCPClient host port = withSocketsDo $ resolve >>= tryOpen err
|
|
where
|
|
err :: IOException
|
|
err = mkIOError NoSuchThing "no address" Nothing Nothing
|
|
|
|
resolve :: IO [AddrInfo]
|
|
resolve =
|
|
let hints = defaultHints {addrSocketType = Stream}
|
|
in getAddrInfo (Just hints) (Just host) (Just port)
|
|
|
|
tryOpen :: IOException -> [AddrInfo] -> IO Socket
|
|
tryOpen e [] = E.throwIO e
|
|
tryOpen _ (addr : as) =
|
|
E.try (open addr) >>= either (`tryOpen` as) pure
|
|
|
|
open :: AddrInfo -> IO Socket
|
|
open addr = do
|
|
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
|
|
connect sock $ addrAddress addr
|
|
pure sock
|
|
|
|
defaultSMPPort :: PortNumber
|
|
defaultSMPPort = 5223
|
|
|
|
connectSocksClient :: SocksProxy -> Maybe SocksCredentials -> SocksHostAddress -> ServiceName -> IO Socket
|
|
connectSocksClient (SocksProxy addr) socksCreds hostAddr _port = do
|
|
let port = if null _port then defaultSMPPort else fromMaybe defaultSMPPort $ readMaybe _port
|
|
fst <$> case socksCreds of
|
|
Just creds -> socksConnectAuth (defaultSocksConf addr) (SocksAddress hostAddr port) creds
|
|
_ -> socksConnect (defaultSocksConf addr) (SocksAddress hostAddr port)
|
|
|
|
defaultSocksHost :: (Word8, Word8, Word8, Word8)
|
|
defaultSocksHost = (127, 0, 0, 1)
|
|
|
|
defaultSocksProxyWithAuth :: SocksProxyWithAuth
|
|
defaultSocksProxyWithAuth = SocksProxyWithAuth SocksIsolateByAuth defaultSocksProxy
|
|
|
|
defaultSocksProxy :: SocksProxy
|
|
defaultSocksProxy = SocksProxy $ SockAddrInet 9050 $ tupleToHostAddress defaultSocksHost
|
|
|
|
newtype SocksProxy = SocksProxy SockAddr
|
|
deriving (Eq)
|
|
|
|
data SocksProxyWithAuth = SocksProxyWithAuth SocksAuth SocksProxy
|
|
deriving (Eq, Show)
|
|
|
|
data SocksAuth
|
|
= SocksAuthUsername {username :: ByteString, password :: ByteString}
|
|
| SocksAuthNull
|
|
| SocksIsolateByAuth -- this is default
|
|
deriving (Eq, Show)
|
|
|
|
instance Show SocksProxy where show (SocksProxy addr) = show addr
|
|
|
|
instance StrEncoding SocksProxy where
|
|
strEncode = B.pack . show
|
|
strP = do
|
|
host <- fromMaybe (THIPv4 defaultSocksHost) <$> optional strP
|
|
port <- fromMaybe 9050 <$> optional (A.char ':' *> (fromInteger <$> A.decimal))
|
|
SocksProxy <$> socksAddr port host
|
|
where
|
|
socksAddr port = \case
|
|
THIPv4 addr -> pure $ SockAddrInet port $ tupleToHostAddress addr
|
|
THIPv6 addr -> pure $ SockAddrInet6 port 0 addr 0
|
|
_ -> fail "SOCKS5 host should be IPv4 or IPv6 address"
|
|
|
|
instance StrEncoding SocksProxyWithAuth where
|
|
strEncode (SocksProxyWithAuth auth proxy) = strEncode auth <> strEncode proxy
|
|
strP = SocksProxyWithAuth <$> strP <*> strP
|
|
|
|
instance ToJSON SocksProxyWithAuth where
|
|
toJSON = strToJSON
|
|
toEncoding = strToJEncoding
|
|
|
|
instance FromJSON SocksProxyWithAuth where
|
|
parseJSON = strParseJSON "SocksProxyWithAuth"
|
|
|
|
instance StrEncoding SocksAuth where
|
|
strEncode = \case
|
|
SocksAuthUsername {username, password} -> username <> ":" <> password <> "@"
|
|
SocksAuthNull -> "@"
|
|
SocksIsolateByAuth -> ""
|
|
strP = usernameP <|> (SocksAuthNull <$ A.char '@') <|> pure SocksIsolateByAuth
|
|
where
|
|
usernameP = do
|
|
username <- A.takeTill (== ':') <* A.char ':'
|
|
password <- A.takeTill (== '@') <* A.char '@'
|
|
pure SocksAuthUsername {username, password}
|
|
|
|
mkTLSClientParams :: T.Supported -> Maybe XS.CertificateStore -> HostName -> ServiceName -> Maybe C.KeyHash -> Maybe T.Credential -> IORef Bool -> Maybe [ALPN] -> Bool -> TMVar (Maybe X.CertificateChain) -> T.ClientParams
|
|
mkTLSClientParams supported caStore_ host port cafp_ clientCreds_ clientCredsSent alpn_ sni serverCerts =
|
|
(T.defaultParamsClient host p)
|
|
{ T.clientUseServerNameIndication = sni,
|
|
T.clientShared = def {T.sharedCAStore = fromMaybe (T.sharedCAStore def) caStore_},
|
|
T.clientHooks =
|
|
def
|
|
{ T.onServerCertificate = onServerCert,
|
|
T.onCertificateRequest = onCertRequest,
|
|
T.onSuggestALPN = pure alpn_
|
|
},
|
|
T.clientSupported = supported
|
|
}
|
|
where
|
|
p = B.pack port
|
|
onServerCert _ _ _ cc = do
|
|
errs <- maybe def (\ca -> validateCertificateChain ca host p cc) cafp_
|
|
atomically $ putTMVar serverCerts $ if null errs then Just cc else Nothing
|
|
pure errs
|
|
onCertRequest = case clientCreds_ of
|
|
Just _ -> \_ -> clientCreds_ <$ writeIORef clientCredsSent True
|
|
Nothing -> \_ -> pure Nothing
|
|
|
|
validateCertificateChain :: C.KeyHash -> HostName -> ByteString -> X.CertificateChain -> IO [XV.FailedReason]
|
|
validateCertificateChain (C.KeyHash kh) host port cc = case chainIdCaCerts cc of
|
|
CCEmpty -> pure [XV.EmptyChain]
|
|
CCSelf _ -> pure [XV.EmptyChain]
|
|
CCValid {idCert, caCert} -> validate idCert caCert
|
|
CCLong -> pure [XV.AuthorityTooDeep]
|
|
where
|
|
validate idCert caCert
|
|
| Fingerprint kh == XV.getFingerprint idCert X.HashSHA256 = x509validate caCert (host, port) cc
|
|
| otherwise = pure [XV.UnknownCA]
|