Files
simplexmq/tests/RemoteControl.hs
sh 74a86043cc lib: parse bracketed IPv6 server addresses (#1807)
* Parse bracketed IPv6 server hosts

* lib: parse service-scheme and invitation hosts via TransportHost

* correct encoding

* encoding

---------

Co-authored-by: Paul Bottinelli <paul.bottinelli@trailofbits.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2026-06-21 12:18:34 +01:00

276 lines
10 KiB
Haskell

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module RemoteControl where
import AgentTests.FunctionalAPITests (runRight)
import Control.Logger.Simple
import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.List (stripPrefix)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Time.Clock.System (SystemTime (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.Messaging.Transport (TSbChainKeys (..))
import Simplex.Messaging.Transport.Client (TransportHost)
import qualified Simplex.RemoteControl.Client as HC (RCHostClient (action))
import qualified Simplex.RemoteControl.Client as RC
import Simplex.RemoteControl.Discovery (mkLastLocalHost, preferAddress)
import Simplex.RemoteControl.Invitation
( RCInvitation (..),
RCSignedInvitation,
verifySignedInvitation,
)
import Simplex.RemoteControl.Types
import Test.Hspec hiding (fit, it)
import UnliftIO
import UnliftIO.Concurrent
import Util
remoteControlTests :: Spec
remoteControlTests = do
describe "preferred bindings should go first" testPreferAddress
describe "Invitation parsing" $ do
it "should parse bracketed IPv6 host with port" testInvitationBracketedIPv6Host
it "should reject bracketed non-IPv6 host" testInvitationBracketedNonIPv6HostRejected
describe "New controller/host pairing" $ do
it "should connect to new pairing" testNewPairing
it "should connect to existing pairing" testExistingPairing
describe "Multicast discovery" $ do
it "should find paired host and connect" testMulticast
testPreferAddress :: Spec
testPreferAddress = do
it "suppresses localhost" $
mkLastLocalHost addrs
`shouldBe` [ "10.20.30.40" `on` "eth0",
"10.20.30.42" `on` "wlan0",
"127.0.0.1" `on` "lo"
]
it "finds by address" $ do
preferAddress ("127.0.0.1" `on` "lo23") addrs' `shouldBe` addrs -- localhost is back on top
preferAddress ("10.20.30.42" `on` "wlp2s0") addrs'
`shouldBe` [ "10.20.30.42" `on` "wlan0",
"10.20.30.40" `on` "eth0",
"127.0.0.1" `on` "lo"
]
it "finds by interface" $ do
preferAddress ("127.1.2.3" `on` "lo") addrs' `shouldBe` addrs
preferAddress ("0.0.0.0" `on` "eth0") addrs' `shouldBe` addrs'
it "survives duplicates" $ do
preferAddress ("0.0.0.0" `on` "eth1") addrsDups `shouldBe` addrsDups
preferAddress ("0.0.0.0" `on` "eth0") ifaceDups `shouldBe` ifaceDups
where
on th interface = RCCtrlAddress {address = either error id $ strDecode th, interface}
addrs =
[ "127.0.0.1" `on` "lo", -- localhost may go first and break things
"10.20.30.40" `on` "eth0",
"10.20.30.42" `on` "wlan0"
]
addrs' = mkLastLocalHost addrs
addrsDups = "10.20.30.40" `on` "eth1" : addrs'
ifaceDups = "10.20.30.41" `on` "eth0" : addrs'
testInvitationBracketedIPv6Host :: IO ()
testInvitationBracketedIPv6Host = do
invitation <- testIPv6Invitation
let bracketedUri =
B.pack . replaceFirst "@2001:db8::1:" "@[2001:db8::1]:" . B.unpack $
strEncode invitation
expectedHost = either error id (strDecode "2001:db8::1") :: TransportHost
case strDecode bracketedUri of
Left err -> expectationFailure err
Right RCInvitation {host, port} -> do
host `shouldBe` expectedHost
port `shouldBe` 5223
testInvitationBracketedNonIPv6HostRejected :: IO ()
testInvitationBracketedNonIPv6HostRejected = do
invitation <- testIPv6Invitation
let bracketedUri =
B.pack . replaceFirst "@2001:db8::1:" "@[simplex.chat]:" . B.unpack $
strEncode invitation
case strDecode bracketedUri :: Either String RCInvitation of
Left _ -> pure ()
Right _ -> expectationFailure "expected parse failure for bracketed non-IPv6 host"
replaceFirst :: String -> String -> String -> String
replaceFirst needle replacement = go
where
go [] = []
go input@(c : cs) =
case stripPrefix needle input of
Just rest -> replacement <> rest
Nothing -> c : go cs
testIPv6Invitation :: IO RCInvitation
testIPv6Invitation = do
drg <- C.newRandom
(skey, _) <- atomically $ C.generateKeyPair @'C.Ed25519 drg
(idkey, _) <- atomically $ C.generateKeyPair @'C.Ed25519 drg
(dh, _) <- atomically $ C.generateKeyPair @'C.X25519 drg
pure
RCInvitation
{ ca = C.KeyHash "test-ca",
host = either error id $ strDecode "2001:db8::1",
port = 5223,
v = supportedRCPVRange,
app = J.String "app",
ts = MkSystemTime 0 0,
skey,
idkey,
dh
}
testNewPairing :: IO ()
testNewPairing = do
drg <- C.newRandom
hp <- RC.newRCHostPairing drg
invVar <- newEmptyMVar
ctrlSessId <- async . runRight $ do
logNote "c 1"
(_found, inv, hc, r) <- RC.connectRCHost drg hp (J.String "app") False Nothing Nothing
logNote "c 2"
putMVar invVar (inv, hc)
logNote "c 3"
Right (sessId, _tls, r') <- atomically $ takeTMVar r
logNote "c 4"
Right (rcHostSession, _rcHelloBody, _hp') <- atomically $ takeTMVar r'
let RCHostSession {tls, sessionKeys = HostSessKeys {chainKeys}} = rcHostSession
TSbChainKeys {rcvKey, sndKey} = chainKeys
sndKeyNonce <- atomically $ stateTVar sndKey C.sbcHkdf
encCmd <- RC.rcEncryptBody sndKeyNonce "command message"
RC.sendRCPacket tls $ LB.toStrict encCmd
encResp <- RC.receiveRCPacket tls
rcvKeyNonce <- atomically $ stateTVar rcvKey C.sbcHkdf
resp <- RC.rcDecryptBody rcvKeyNonce $ LB.fromStrict encResp
liftIO $ resp `shouldBe` "response message"
logNote "c 5"
threadDelay 250000
logNote "ctrl: ciao"
liftIO $ RC.cancelHostClient hc
pure sessId
(signedInv, hc) <- takeMVar invVar
-- logNote $ decodeUtf8 $ strEncode inv
inv <- maybe (fail "bad invite") pure $ verifySignedInvitation signedInv
hostSessId <- async . runRight $ do
logNote "h 1"
(rcCtrlClient, r) <- RC.connectRCCtrl drg inv Nothing (J.String "app")
logNote "h 2"
Right (sessId', _tls, r') <- atomically $ takeTMVar r
logNote "h 3"
liftIO $ RC.confirmCtrlSession rcCtrlClient True
logNote "h 4"
Right (rcCtrlSession, _rcCtrlPairing) <- atomically $ takeTMVar r'
let RCCtrlSession {tls, sessionKeys = CtrlSessKeys {chainKeys}} = rcCtrlSession
TSbChainKeys {rcvKey, sndKey} = chainKeys
encCmd <- RC.receiveRCPacket tls
rcvKeyNonce <- atomically $ stateTVar rcvKey C.sbcHkdf
cmd <- RC.rcDecryptBody rcvKeyNonce $ LB.fromStrict encCmd
liftIO $ cmd `shouldBe` "command message"
sndKeyNonce <- atomically $ stateTVar sndKey C.sbcHkdf
encResp <- RC.rcEncryptBody sndKeyNonce "response message"
RC.sendRCPacket tls $ LB.toStrict encResp
logNote "h 5"
threadDelay 250000
logNote "ctrl: adios"
pure sessId'
waitCatch (HC.action hc) >>= \case
Left err -> fromException err `shouldBe` Just AsyncCancelled
Right () -> fail "Unexpected controller finish"
timeout 5000000 (waitBoth ctrlSessId hostSessId) >>= \case
Just (sessId, sessId') -> sessId `shouldBe` sessId'
_ -> fail "timeout"
testExistingPairing :: IO ()
testExistingPairing = do
drg <- C.newRandom
invVar <- newEmptyMVar
hp <- RC.newRCHostPairing drg
ctrl <- runCtrl drg False hp invVar
inv <- takeMVar invVar
let cp_ = Nothing
host <- runHostURI drg cp_ inv
timeout 5000000 (waitBoth ctrl host) >>= \case
Nothing -> fail "timeout"
Just (hp', cp') -> do
ctrl' <- runCtrl drg False hp' invVar
inv' <- takeMVar invVar
host' <- runHostURI drg (Just cp') inv'
timeout 5000000 (waitBoth ctrl' host') >>= \case
Nothing -> fail "timeout"
Just (_hp2, cp2) -> do
ctrl2 <- runCtrl drg False hp' invVar -- old host pairing used to test controller not updating state
inv2 <- takeMVar invVar
host2 <- runHostURI drg (Just cp2) inv2
timeout 5000000 (waitBoth ctrl2 host2) >>= \case
Nothing -> fail "timeout"
Just (hp3, cp3) -> do
ctrl3 <- runCtrl drg False hp3 invVar
inv3 <- takeMVar invVar
host3 <- runHostURI drg (Just cp3) inv3
timeout 5000000 (waitBoth ctrl3 host3) >>= \case
Nothing -> fail "timeout"
Just _ -> pure ()
testMulticast :: IO ()
testMulticast = do
drg <- C.newRandom
subscribers <- newTMVarIO 0
invVar <- newEmptyMVar
hp <- RC.newRCHostPairing drg
ctrl <- runCtrl drg False hp invVar
inv <- takeMVar invVar
let cp_ = Nothing
host <- runHostURI drg cp_ inv
timeout 5000000 (waitBoth ctrl host) >>= \case
Nothing -> fail "timeout"
Just (hp', cp') -> do
ctrl' <- runCtrl drg True hp' invVar
_inv <- takeMVar invVar
host' <- runHostMulticast drg subscribers cp'
timeout 5000000 (waitBoth ctrl' host') >>= \case
Nothing -> fail "timeout"
Just _ -> pure ()
runCtrl :: TVar ChaChaDRG -> Bool -> RCHostPairing -> MVar RCSignedInvitation -> IO (Async RCHostPairing)
runCtrl drg multicast hp invVar = async . runRight $ do
(_found, inv, hc, r) <- RC.connectRCHost drg hp (J.String "app") multicast Nothing Nothing
putMVar invVar inv
Right (_sessId, _tls, r') <- atomically $ takeTMVar r
Right (_rcHostSession, _rcHelloBody, hp') <- atomically $ takeTMVar r'
threadDelay 250000
liftIO $ RC.cancelHostClient hc
pure hp'
runHostURI :: TVar ChaChaDRG -> Maybe RCCtrlPairing -> RCSignedInvitation -> IO (Async RCCtrlPairing)
runHostURI drg cp_ signedInv = async . runRight $ do
inv <- maybe (fail "bad invite") pure $ verifySignedInvitation signedInv
(rcCtrlClient, r) <- RC.connectRCCtrl drg inv cp_ (J.String "app")
Right (_sessId', _tls, r') <- atomically $ takeTMVar r
liftIO $ RC.confirmCtrlSession rcCtrlClient True
Right (_rcCtrlSession, cp') <- atomically $ takeTMVar r'
threadDelay 250000
pure cp'
runHostMulticast :: TVar ChaChaDRG -> TMVar Int -> RCCtrlPairing -> IO (Async RCCtrlPairing)
runHostMulticast drg subscribers cp = async . runRight $ do
(pairing, inv) <- RC.discoverRCCtrl subscribers (cp :| [])
(rcCtrlClient, r) <- RC.connectRCCtrl drg inv (Just pairing) (J.String "app")
Right (_sessId', _tls, r') <- atomically $ takeTMVar r
liftIO $ RC.confirmCtrlSession rcCtrlClient True
Right (_rcCtrlSession, cp') <- atomically $ takeTMVar r'
threadDelay 250000
pure cp'