store: merge names wiring

This commit is contained in:
shum committed 2026-10-21 22:21:08 +00:00
commit 1d99a0796d
14 files changed
+1076 -257

No files matched your search

@@ -12,6 +12,8 @@ module StoreService.Config
PollConfig (..),
BadgeIssuerKey (..),
GroupConfig (..),
NamesConfig (..),
ChainConfig (..),
ServiceConfig (..),
defaultExpiryMinutes,
defaultSessionMinutes,
@@ -22,17 +24,26 @@ where
import qualified Control.Exception as E
import StoreService.Log (logWarn)
import Control.Monad (mfilter)
import Control.Monad (mfilter, unless, when)
import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile)
import qualified Data.ByteArray as BA
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
import qualified Data.ByteString.Char8 as B
import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text.IO as TIO
import Data.Time.Clock (UTCTime)
import Data.Time.Format.ISO8601 (iso8601ParseM)
import Data.Word (Word64)
import Simplex.Chat.Library.Commands (mkValidName)
import Simplex.Messaging.Crypto.BBS (BBSSecretKey)
import Simplex.Messaging.Crypto.Secp256k1 (Secp256k1PrivateKey, mkPrivateKey)
import Simplex.Messaging.Encoding.String (strDecode)
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.SimplexName (SimplexTLD (..))
import System.IO.Error (ioeGetErrorString)
import Text.Read (readMaybe)
@@ -42,7 +53,10 @@ data ListenerConfig = ListenerConfig
lStaticDir :: FilePath,
lServeWebapp :: Bool,
lWebappExportDir :: Maybe FilePath,
lTrustForwardedFor :: Bool
lTrustForwardedFor :: Bool,
lNamesStaticDir :: Maybe FilePath,
lNamesExportDir :: Maybe FilePath,
lNamesHosts :: [Text]
}
deriving (Eq, Show)
@@ -107,13 +121,42 @@ data GroupConfig = GroupConfig
}
deriving (Eq, Show)
data NamesConfig = NamesConfig
{ nTLD :: SimplexTLD,
nYears :: Int,
nTermStart :: UTCTime,
nTermSlackSeconds :: Int,
nRevealMarginSeconds :: Int,
nMaxOrderMoves :: Int,
nChain :: ChainConfig
}
deriving (Eq, Show)
data ChainConfig = ChainConfig
{ cRpcUrl :: String,
cChainId :: Word64,
cController :: Address,
cSponsor :: Address,
cBaseRegistrar :: Address,
cResolver :: Address,
cServiceKey :: Secp256k1PrivateKey,
cConfirmations :: Int,
cGasLimitCap :: Word64
}
deriving (Eq)
-- The RPC URL can carry a provider's API key, so neither it nor the service key is shown.
instance Show ChainConfig where
show ChainConfig {cChainId} = "chain " <> show cChainId
data ServiceConfig = ServiceConfig
{ listener :: ListenerConfig,
btcpay :: Maybe BTCPayConfig,
stripe :: Maybe StripeConfig,
poll :: PollConfig,
issuer :: Maybe BadgeIssuerKey,
group :: Maybe GroupConfig
group :: Maybe GroupConfig,
names :: Maybe NamesConfig
}
deriving (Eq, Show)
@@ -137,6 +180,16 @@ requireHttps u
| "https://" `T.isPrefixOf` u = Right u
| otherwise = Left "btcpay.host must be an absolute https URL"
-- | The controller refuses a registration under 730 days.
minNameYears :: Int
minNameYears = 2
defaultTermStart :: String
defaultTermStart = "2027-01-01T00:00:00Z"
serviceKeyBytes :: Int
serviceKeyBytes = 32
-- | Well below 100, where BTCPay settles an invoice for one satoshi.
maxTolerance :: Double
maxTolerance = 10
@@ -160,12 +213,14 @@ trailingNoise = skipSpace *> skipMany (comment *> skipSpace)
knownSettings :: [(Text, [Text])]
knownSettings =
[ ("listener", ["host", "port", "static_dir", "serve_webapp", "webapp_export_dir", "trust_forwarded_for"]),
[ ("listener", ["host", "port", "static_dir", "serve_webapp", "webapp_export_dir", "trust_forwarded_for", "names_static_dir", "names_export_dir", "names_hosts"]),
("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]),
("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]),
("poll", ["waiting_seconds", "idle_seconds"]),
("group", ["display_name", "description"]),
("issuer", ["index", "private_key"])
("issuer", ["index", "private_key"]),
("names", ["tld", "years", "term_start", "term_slack_seconds", "reveal_margin_seconds", "max_order_moves"]),
("chain", ["rpc_url", "chain_id", "controller", "sponsor", "base_registrar", "resolver", "service_key", "confirmations", "gas_limit_cap"])
]
unknownKeys :: Ini -> [Text]
@@ -190,20 +245,25 @@ parseConfig ini = do
lServeWebapp <- bool "listener" "serve_webapp" True
let lWebappExportDir = T.unpack <$> present "listener" "webapp_export_dir"
lTrustForwardedFor <- bool "listener" "trust_forwarded_for" False
let lNamesStaticDir = T.unpack <$> present "listener" "names_static_dir"
lNamesExportDir = T.unpack <$> present "listener" "names_export_dir"
lNamesHosts = maybe [] (filter (not . T.null) . map (T.toLower . T.strip) . T.splitOn ",") (present "listener" "names_hosts")
btc <- btcpaySection
str <- stripeSection
iss <- issuerSection
grp <- groupSection
nms <- namesSection
pWaitingSeconds <- cadence "waiting_seconds" 3
pIdleSeconds <- cadence "idle_seconds" 60
pure
ServiceConfig
{ listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor},
{ listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor, lNamesStaticDir, lNamesExportDir, lNamesHosts},
btcpay = btc,
stripe = str,
poll = PollConfig {pWaitingSeconds, pIdleSeconds},
issuer = iss,
group = grp
group = grp,
names = nms
}
where
hasSection s = s `elem` sections ini
@@ -295,6 +355,61 @@ parseConfig ini = do
sSessionMinutes <- sessionMinutes
let sHost = defaultStripeHost
pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost})
namesSection
| not (hasSection "names") = if hasSection "chain" then Left "[chain] is set but [names] is absent, so nothing would use it" else Right Nothing
| not (hasSection "chain") = Left "[names] needs a [chain] section"
| otherwise = do
nTLD <- tldSetting
nYears <- num "names" "years" minNameYears
when (nYears < minNameYears) $ Left ("names.years must be at least " <> show minNameYears)
nTermStart <- termStart
nTermSlackSeconds <- atLeast 0 "names" "term_slack_seconds" 3600
nRevealMarginSeconds <- atLeast 0 "names" "reveal_margin_seconds" 900
nMaxOrderMoves <- atLeast 0 "names" "max_order_moves" 3
nChain <- chainSection
pure (Just NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nChain})
tldSetting =
required "names" "tld" >>= \case
"simplex" -> Right TLDSimplex
"testing" -> Right TLDTesting
other -> Left ("names.tld must be simplex or testing, not " <> T.unpack other)
termStart =
maybe (Left ("names.term_start must be an ISO 8601 UTC time such as " <> defaultTermStart)) Right $
iso8601ParseM (maybe defaultTermStart T.unpack (present "names" "term_start"))
atLeast lowest s k d = do
v <- num s k d
if v >= lowest then Right v else Left (T.unpack s <> "." <> T.unpack k <> " must be at least " <> show lowest)
-- No error below quotes a value, since the URL and the key are secrets.
chainSection = do
cRpcUrl <- rpcUrl
cChainId <- positiveWord "chain_id" Nothing
cController <- address "controller"
cSponsor <- address "sponsor"
cBaseRegistrar <- address "base_registrar"
cResolver <- address "resolver"
cServiceKey <- serviceKey
cConfirmations <- atLeast 0 "chain" "confirmations" 3
cGasLimitCap <- positiveWord "gas_limit_cap" (Just 8000000)
pure ChainConfig {cRpcUrl, cChainId, cController, cSponsor, cBaseRegistrar, cResolver, cServiceKey, cConfirmations, cGasLimitCap}
rpcUrl = do
v <- required "chain" "rpc_url"
unless (any (`T.isPrefixOf` v) ["http://", "https://"]) $ Left "chain.rpc_url must be an http or https URL"
Right (T.unpack v)
positiveWord k d = case (present "chain" k, d) of
(Nothing, Just v) -> Right v
(Nothing, Nothing) -> Left ("chain." <> T.unpack k <> " is required")
(Just v, _) -> case readMaybe (T.unpack v) :: Maybe Integer of
Just n | n >= 1, n <= toInteger (maxBound :: Word64) -> Right (fromInteger n)
_ -> Left ("chain." <> T.unpack k <> " must be a positive whole number")
address k = do
v <- required "chain" k
either (const (Left ("chain." <> T.unpack k <> " is not an Ethereum address"))) Right (strDecode (encodeUtf8 v) :: Either String Address)
serviceKey = do
v <- required "chain" "service_key"
let bad = Left "chain.service_key must be 0x and 64 hex digits of a valid secp256k1 key"
case T.stripPrefix "0x" v of
Just h | T.length h == 2 * serviceKeyBytes, Right bytes <- convertFromBase Base16 (encodeUtf8 h) -> either (const bad) Right (mkPrivateKey (BA.convert (bytes :: B.ByteString)))
_ -> bad
sessionMinutes = do
v <- num "stripe" "session_minutes" defaultSessionMinutes
if v >= minSessionMinutes && v <= maxSessionMinutes
@@ -0,0 +1,25 @@
{-# LANGUAGE NamedFieldPuns #-}
module StoreService.Names.Env (NamesEnv (..), newNamesEnv) where
import Simplex.Chat.Controller (ChatController)
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.Common (DBStore)
import StoreService.Config (NamesConfig)
import StoreService.Names.Phases (Phases, newPhases)
import StoreService.Names.Turns (Turns, newTurns)
data NamesEnv = NamesEnv
{ neChat :: ChatController,
neUserId :: UserId,
neConfig :: NamesConfig,
neStore :: DBStore,
nePhases :: Phases,
neTurns :: Turns
}
newNamesEnv :: ChatController -> UserId -> NamesConfig -> DBStore -> IO NamesEnv
newNamesEnv neChat neUserId neConfig neStore = do
nePhases <- newPhases
neTurns <- newTurns
pure NamesEnv {neChat, neUserId, neConfig, neStore, nePhases, neTurns}
@@ -0,0 +1,57 @@
{-# LANGUAGE NamedFieldPuns #-}
module StoreService.Names.Phases (Phases, RegistrationId (..), newPhases, publish, awaitChange) where
import Control.Concurrent.STM
import Control.Exception (bracket)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
newtype RegistrationId = RegistrationId Text
deriving (Eq, Ord, Show)
-- | wVersion only grows, so a publish that arrives after a newer one cannot wake anyone.
data Watch = Watch {wVersion :: TVar Int64, wRefs :: TVar Int}
newtype Phases = Phases (TVar (Map RegistrationId Watch))
newPhases :: IO Phases
newPhases = Phases <$> newTVarIO Map.empty
-- | Call after the transaction that wrote this version commits.
publish :: Phases -> RegistrationId -> Int64 -> STM ()
publish (Phases watches) rid version =
readTVar watches >>= mapM_ (\Watch {wVersion} -> modifyTVar' wVersion (max version)) . Map.lookup rid
subscribe :: Phases -> RegistrationId -> STM Watch
subscribe (Phases watches) rid =
readTVar watches >>= \ws -> case Map.lookup rid ws of
Just watch@Watch {wRefs} -> watch <$ modifyTVar' wRefs (+ 1)
Nothing -> do
watch <- Watch <$> newTVar minBound <*> newTVar 1
watch <$ writeTVar watches (Map.insert rid watch ws)
release :: Phases -> RegistrationId -> STM ()
release (Phases watches) rid =
readTVar watches >>= \ws -> mapM_ (drop' ws) (Map.lookup rid ws)
where
drop' ws Watch {wRefs} = do
n <- pred <$> readTVar wRefs
if n <= 0 then writeTVar watches (Map.delete rid ws) else writeTVar wRefs n
-- | True once the version differs from @seen@, False after @micros@ without a change.
-- Subscribes before reading the version, so a publish between the read and the wait is not missed.
awaitChange :: Phases -> RegistrationId -> IO Int64 -> Int64 -> Int -> IO Bool
awaitChange phases rid readVersion seen micros =
bracket (atomically $ subscribe phases rid) (const . atomically $ release phases rid) $ \Watch {wVersion} -> do
current <- readVersion
atomically $ modifyTVar' wVersion (max current)
if current /= seen
then pure True
else do
timer <- registerDelay micros
atomically $
(readTVar wVersion >>= \v -> if v > seen then pure True else retry)
`orElse` (False <$ (readTVar timer >>= check))
@@ -0,0 +1,46 @@
{-# LANGUAGE NamedFieldPuns #-}
module StoreService.Names.Turns (Turns, newTurns, withLabelTurn, withLabelTurns, withCodeTurn) where
import Control.Concurrent.STM
import Control.Exception (bracket, bracket_)
import Data.ByteString (ByteString)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Simplex.Messaging.SimplexName (LabelHash (..))
-- | Each lock counts the requests holding or waiting for it, and its entry goes when none are left.
type TurnLocks = TVar (Map ByteString (TMVar (), Int))
data Turns = Turns {labelTurns :: TurnLocks, codeTurns :: TurnLocks}
newTurns :: IO Turns
newTurns = Turns <$> newTVarIO Map.empty <*> newTVarIO Map.empty
withLabelTurn :: Turns -> LabelHash -> IO a -> IO a
withLabelTurn Turns {labelTurns} (LabelHash h) = withTurn labelTurns h
-- | Taken in hash order, so two requests moving between the same names cannot each hold one and wait for the other.
withLabelTurns :: Turns -> LabelHash -> LabelHash -> IO a -> IO a
withLabelTurns turns a@(LabelHash ha) b@(LabelHash hb) action
| ha == hb = withLabelTurn turns a action
| ha < hb = withLabelTurn turns a $ withLabelTurn turns b action
| otherwise = withLabelTurn turns b $ withLabelTurn turns a action
-- | Keyed by the code's SHA-256 hash.
withCodeTurn :: Turns -> ByteString -> IO a -> IO a
withCodeTurn Turns {codeTurns} = withTurn codeTurns
withTurn :: TurnLocks -> ByteString -> IO a -> IO a
withTurn locks key action =
bracket (atomically enter) (const $ atomically leave) $ \lock ->
bracket_ (atomically $ takeTMVar lock) (atomically $ putTMVar lock ()) action
where
enter = do
held <- readTVar locks
case Map.lookup key held of
Just (lock, n) -> lock <$ writeTVar locks (Map.insert key (lock, n + 1) held)
Nothing -> do
lock <- newTMVar ()
lock <$ writeTVar locks (Map.insert key (lock, 1) held)
leave = modifyTVar' locks (Map.update (\(lock, n) -> if n <= 1 then Nothing else Just (lock, n - 1)) key)
@@ -20,9 +20,10 @@ where
import StoreService.Catalog (defaultCatalog)
import StoreService.Codes (issueFailedText, issueOneCode, singleUse)
import StoreService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig)
import StoreService.Config (BadgeIssuerKey (..), ChainConfig (..), NamesConfig (..), ServiceConfig (..), readServiceConfig)
import StoreService.Group (GroupEvent, ensureManagedGroup, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane)
import StoreService.Group.Command (badgeTypeP, codeP, maxMonths, textTokenP)
import StoreService.Names.Env (NamesEnv, newNamesEnv)
import StoreService.Options
import StoreService.Poller (newPollerEnv, newReadHints, runPoller)
import StoreService.Providers.BTCPay (btcpayProvider)
@@ -150,18 +151,27 @@ storeService opts@StoreServiceOpts {serviceConfigFile} cfg env = do
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> raceAny_ [readEvents cc, startLanes cc]
where
serviceLanes :: Waiters -> ChatController -> ServiceConfig -> IO [IO ()]
serviceLanes ws ChatController {chatStore} sc = do
serviceLanes ws cc@ChatController {chatStore} sc = do
-- Seed before the listener accepts anything, since every checkout is priced from these.
seedServiceCatalog chatStore
btc <- maybe (pure []) (fmap (: []) . btcpayProvider) (btcpay sc)
str <- maybe (pure []) (fmap (: []) . stripeProvider) (stripe sc)
let providers = btc <> str
hints <- newReadHints
webEnv <- newWebEnv chatStore sc ws hints providers
namesEnv <- forM (names sc) (startNames cc)
webEnv <- newWebEnv chatStore sc ws hints providers namesEnv
exportWebapp (listener sc) (stripe sc)
pollerEnv <- newPollerEnv chatStore ws hints providers (poll sc)
pure [runWebListener webEnv, runPoller pollerEnv]
startNames :: ChatController -> NamesConfig -> IO NamesEnv
startNames cc@ChatController {chatStore} nc@NamesConfig {nTLD, nChain = ChainConfig {cChainId}} =
readTVarIO (currentUser cc) >>= \case
Nothing -> putStrLn "No current user" >> exitFailure
Just User {userId} -> do
logInfo $ "names: on, TLD " <> safeDecodeUtf8 (strEncode nTLD) <> ", chain " <> tshow cChainId
newNamesEnv cc userId nc chatStore
seedServiceCatalog :: DBStore -> IO ()
seedServiceCatalog st = do
now <- truncateToSecond <$> getCurrentTime
@@ -0,0 +1,283 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module StoreService.Web.Http
( HttpEnv (..),
newHttpEnv,
Respond,
Limit (..),
takeToken,
limited,
only,
clientKey,
maxBuckets,
readLimit,
createLimit,
searchLimit,
previewLimit,
codeLimit,
findLimit,
recordsLimit,
holdMicros,
jsonResponse,
jsonError,
notFound,
badRequest,
codeConflict,
providerUnavailable,
rateLimited,
methodNotAllowed,
internalError,
readBoundedBody,
maxBodyBytes,
nameBodyBytes,
parseCodeHash,
)
where
import Control.Concurrent.STM
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64U
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import Data.List (sortOn)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8', encodeUtf8)
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
import Data.Word (Word16)
import Network.HTTP.Types (Header, Method, Status, hCacheControl, hContentType, status400, status404, status405, status409, status429, status500, status503)
import Network.Socket (SockAddr (..), hostAddress6ToTuple, hostAddressToTuple)
import Network.Wai
import Numeric (showHex)
import Simplex.Messaging.Util (safeDecodeUtf8)
import StoreService.Config (ListenerConfig (..))
import Text.Read (readMaybe)
data HttpEnv = HttpEnv
{ heBuckets :: TVar (Map Text Bucket),
heTrustForwardedFor :: Bool
}
newHttpEnv :: ListenerConfig -> IO HttpEnv
newHttpEnv ListenerConfig {lTrustForwardedFor} = do
heBuckets <- newTVarIO Map.empty
pure HttpEnv {heBuckets, heTrustForwardedFor = lTrustForwardedFor}
type Respond = Response -> IO ResponseReceived
-- | Short enough to stay under the idle timeout of any proxy in front of us.
holdMicros :: Int
holdMicros = 30 * 1000000
readLimit, createLimit, searchLimit, previewLimit, codeLimit, findLimit, recordsLimit :: Limit
readLimit = Limit {lmName = "read", lmPerMinute = 60}
createLimit = Limit {lmName = "create", lmPerMinute = 5}
searchLimit = Limit {lmName = "search", lmPerMinute = 30}
previewLimit = Limit {lmName = "preview", lmPerMinute = 20}
codeLimit = Limit {lmName = "code", lmPerMinute = 10}
findLimit = Limit {lmName = "find", lmPerMinute = 5}
recordsLimit = Limit {lmName = "records", lmPerMinute = 10}
maxBodyBytes :: Int
maxBodyBytes = 8192
-- | The cap of the names endpoints whose bodies carry a sealed name code.
nameBodyBytes :: Int
nameBodyBytes = 16384
sha256Bytes :: Int
sha256Bytes = 32
codeHashChars :: Int
codeHashChars = 43
data Limit = Limit {lmName :: Text, lmPerMinute :: Int}
deriving (Eq, Show)
data Bucket = Bucket {bkCount :: Int, bkStarted :: UTCTime}
limitWindow :: NominalDiffTime
limitWindow = 60
maxBuckets :: Int
maxBuckets = 32768
reclaim :: UTCTime -> Map Text Bucket -> Map Text Bucket
reclaim now buckets
| Map.size buckets < maxBuckets = buckets
| Map.size live * 2 <= maxBuckets = live
| otherwise = Map.fromList (drop (Map.size live `div` 2) (sortOn (bkCount . snd) (Map.toList live)))
where
live = Map.filter (fresh now) buckets
fresh :: UTCTime -> Bucket -> Bool
fresh now Bucket {bkStarted} = diffUTCTime now bkStarted < limitWindow
takeToken :: HttpEnv -> Limit -> Text -> IO (Maybe Int)
takeToken HttpEnv {heBuckets} Limit {lmName, lmPerMinute} client = do
now <- getCurrentTime
atomically $ do
buckets <- reclaim now <$> readTVar heBuckets
let key = lmName <> "\t" <> client
count bucket = writeTVar heBuckets (Map.insert key bucket buckets) >> pure Nothing
-- write the reclaimed map back here too, or a refused request redoes the filtering every time and the map stays at the limit
refuse seconds = writeTVar heBuckets buckets >> pure (Just seconds)
case Map.lookup key buckets of
Just bucket@Bucket {bkCount, bkStarted}
| fresh now bucket ->
if bkCount < lmPerMinute
then count bucket {bkCount = bkCount + 1}
else refuse (secondsLeft now bkStarted)
_ -> count Bucket {bkCount = 1, bkStarted = now}
where
secondsLeft :: UTCTime -> UTCTime -> Int
secondsLeft now started = max 1 (ceiling (limitWindow - diffUTCTime now started))
limited :: HttpEnv -> Limit -> Request -> Respond -> IO ResponseReceived -> IO ResponseReceived
limited env limit req respond action =
takeToken env limit (clientKey env req) >>= \case
Nothing -> action
Just seconds -> respond (rateLimited seconds)
only :: Method -> Request -> Respond -> IO ResponseReceived -> IO ResponseReceived
only method req respond action
| requestMethod req == method = action
| otherwise = respond (methodNotAllowed method)
-- | Every line is joined first, so a second header line a caller adds cannot leave its own line the one we read.
clientKey :: HttpEnv -> Request -> Text
clientKey HttpEnv {heTrustForwardedFor} req = fromMaybe (peerText (remoteHost req)) forwarded
where
forwarded
| not heTrustForwardedFor = Nothing
| otherwise = case reverse (concatMap entries (requestHeaders req)) of
(ip : _) | isIpAddress ip -> Just ip
_ -> Nothing
entries (name, raw)
| name /= "x-forwarded-for" = []
| otherwise = either (const []) (filter (not . T.null) . map T.strip . T.splitOn ",") (decodeUtf8' raw)
isIpAddress :: Text -> Bool
isIpAddress t = isIPv4 t || isIPv6 t
asciiDigit :: Char -> Bool
asciiDigit c = c >= '0' && c <= '9'
-- Data.Char.isDigit and isHexDigit accept Unicode digits, which are not addresses.
asciiHexDigit :: Char -> Bool
asciiHexDigit c = asciiDigit c || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
-- | A leading zero is refused, so "010.0.0.1" cannot be a second key for "10.0.0.1".
isIPv4 :: Text -> Bool
isIPv4 t = case T.splitOn "." t of
[a, b, c, d] -> all octet [a, b, c, d]
_ -> False
where
octet s =
not (T.null s)
&& T.length s <= 3
&& T.all asciiDigit s
&& (T.length s == 1 || T.head s /= '0')
&& maybe False (<= 255) (readMaybe (T.unpack s) :: Maybe Int)
isIPv6 :: Text -> Bool
isIPv6 t
| T.any (== '%') t = False
| otherwise = case T.splitOn "::" t of
[full] -> groupCount True (segments full) == Just 8
[before, after] -> case (groupCount False (segments before), groupCount True (segments after)) of
(Just b, Just a) -> b + a <= 7 -- "::" stands for at least one omitted group
_ -> False
_ -> False
where
segments s = if T.null s then [] else T.splitOn ":" s
groupCount :: Bool -> [Text] -> Maybe Int
groupCount endsAddress gs
| null gs = Just 0
| endsAddress && isIPv4 (last gs) = (+ 2) <$> plainGroups (init gs)
| otherwise = plainGroups gs
where
plainGroups ps = if all hexGroup ps then Just (length ps) else Nothing
hexGroup g = not (T.null g) && T.length g <= 4 && T.all asciiHexDigit g
peerText :: SockAddr -> Text
peerText = \case
SockAddrInet _ addr ->
let (a, b, c, d) = hostAddressToTuple addr
in T.intercalate "." (map tshowInt [a, b, c, d])
SockAddrInet6 _ _ addr _ ->
let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple addr
in T.intercalate ":" (map hex16 [a, b, c, d, e, f, g, h])
SockAddrUnix p -> "unix:" <> T.pack p
where
tshowInt :: Show a => a -> Text
tshowInt = T.pack . show
hex16 :: Word16 -> Text
hex16 w = T.pack (showHex w "")
apiHeaders :: [Header]
apiHeaders = [(hContentType, "application/json"), (hCacheControl, "no-store")]
jsonResponse :: Status -> J.Value -> Response
jsonResponse st = responseLBS st apiHeaders . J.encode
jsonError :: Status -> Text -> Response
jsonError st code = jsonResponse st (J.object ["error" .= code])
notFound :: Response
notFound = jsonError status404 "not_found"
badRequest :: Response
badRequest = jsonError status400 "bad_request"
codeConflict :: Response
codeConflict = jsonError status409 "code_conflict"
providerUnavailable :: Response
providerUnavailable = jsonError status503 "provider_unavailable"
rateLimited :: Int -> Response
rateLimited seconds =
responseLBS
status429
(("Retry-After", B.pack (show seconds)) : apiHeaders)
(J.encode (J.object ["error" .= ("rate_limited" :: Text)]))
methodNotAllowed :: Method -> Response
methodNotAllowed allow = responseLBS status405 (("Allow", allow) : apiHeaders) (J.encode (J.object ["error" .= ("method_not_allowed" :: Text)]))
internalError :: Response
internalError = jsonError status500 "internal"
-- | Returns the bytes exactly as they arrived, since the webhook signature is over those bytes.
readBoundedBody :: Int -> Request -> IO (Maybe LB.ByteString)
readBoundedBody cap req = go 0 []
where
go :: Int -> [ByteString] -> IO (Maybe LB.ByteString)
go read' acc = do
chunk <- getRequestBodyChunk req
if BS.null chunk
then pure (Just (LB.fromChunks (reverse acc)))
else
let read'' = read' + BS.length chunk
in if read'' > cap then pure Nothing else go read'' (chunk : acc)
-- | The last character of a 43-character base64 string has two bits no digest byte uses, so a lax
-- decoder would accept four spellings of the same digest, which the re-encode check rejects.
parseCodeHash :: Text -> Maybe ByteString
parseCodeHash t
| T.length t /= codeHashChars = Nothing
| otherwise = case B64U.decode (encodeUtf8 (t <> "=")) of
Right bytes | BS.length bytes == sha256Bytes, canonical bytes == t -> Just bytes
_ -> Nothing
where
canonical = T.filter (/= '=') . safeDecodeUtf8 . B64U.encode
@@ -0,0 +1,25 @@
{-# LANGUAGE NamedFieldPuns #-}
module StoreService.Web.Names (Route (..), namesRoutes, namesApp) where
import Data.Text (Text)
import Network.HTTP.Types (Method)
import Network.Wai (Request, ResponseReceived, pathInfo)
import StoreService.Names.Env (NamesEnv)
import StoreService.Web.Http (HttpEnv, Limit, Respond, limited, only)
data Route = Route
{ rMethod :: Method,
rLimit :: Limit,
rHandler :: Request -> Respond -> IO ResponseReceived
}
namesRoutes :: NamesEnv -> [Text] -> Maybe Route
namesRoutes _ _ = Nothing
-- | Nothing for a path no route knows. The limit is taken only once the route and the method match.
namesApp :: HttpEnv -> ([Text] -> Maybe Route) -> Request -> Respond -> Maybe (IO ResponseReceived)
namesApp http routes req respond = serve <$> routes (pathInfo req)
where
serve Route {rMethod, rLimit, rHandler} =
only rMethod req respond $ limited http rLimit req respond $ rHandler req respond
@@ -5,17 +5,12 @@
module StoreService.Web.Server
( WebEnv (..),
Limit (..),
newWebEnv,
exportWebapp,
takeToken,
maxBuckets,
maxWebhookBytes,
webApp,
webSettings,
runWebListener,
holdMicros,
readLimit,
)
where
@@ -24,7 +19,10 @@ import StoreService.Config (BTCPayConfig (..), ListenerConfig (..), ServiceConfi
import StoreService.Poller (ReadHints, queueReadHint)
import StoreService.Providers (OrderDraft (..), Provider (..), ProviderError (..), ProviderInvoice (..), WebhookError (..))
import StoreService.Store.Invoices (CreateError (..), InvoicePayment (..), InvoiceRow (..), NewInvoice (..), cancelOpenInvoice, codeHashExists, createInvoiceRows, cryptoCurrencyText, getInvoice, getInvoiceByProviderRef, invoiceStatusText, newInvoiceId, paymentHolds, readCatalogRows, textToInvoiceStatus, truncateToSecond)
import StoreService.Names.Env (NamesEnv)
import StoreService.Waiters (Seen, Waiters, awaitStatus, publish)
import StoreService.Web.Http
import StoreService.Web.Names (namesApp, namesRoutes)
import Control.Concurrent.STM
import qualified Control.Exception as E
import StoreService.Log (logError, logInfo, logWarn)
@@ -35,33 +33,25 @@ import qualified Data.Aeson.KeyMap as KM
import Data.Aeson.Types (Pair)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64U
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import Data.Char (toLower)
import Data.List (find, isPrefixOf, sortOn)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.List (find, isPrefixOf)
import Data.Maybe (fromMaybe)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8', encodeUtf8)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import Data.Word (Word16)
import Network.HTTP.Types (Header, Status, hCacheControl, hContentType, status200, status400, status404, status405, status409, status413, status429, status500, status503)
import Network.Socket (SockAddr (..), hostAddress6ToTuple, hostAddressToTuple)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime)
import Network.HTTP.Types (Header, Status, hCacheControl, hContentType, status200, status400, status409, status413)
import Network.Wai
import qualified Network.Wai.Handler.Warp as Warp
import Numeric (showHex)
import Simplex.Chat.Badges.Types (BadgeOfferId (..), BadgePriceId (..))
import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), ServicePaymentDestination (..), ServicePaymentMethod (..))
import Simplex.Messaging.Agent.Store.Common (DBStore)
import Simplex.Messaging.Encoding.String (textEncode)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
import Simplex.Messaging.Util (tshow)
import System.Directory (canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory, removePathForcibly)
import System.FilePath (pathSeparator, takeExtension, (</>))
import Text.Read (readMaybe)
data WebEnv = WebEnv
{ weStore :: DBStore,
@@ -70,201 +60,28 @@ data WebEnv = WebEnv
weProviders :: [Provider],
weHoldMicros :: Int,
weHints :: ReadHints,
weBuckets :: TVar (Map Text Bucket),
weHttp :: HttpEnv,
-- | @Nothing@ when no Stripe key is configured, and the pristine @static_dir@ shell is served instead.
weShell :: Maybe LB.ByteString
weShell :: Maybe LB.ByteString,
-- | @Nothing@ when the ini has no @[names]@ section.
weNames :: Maybe NamesEnv
}
newWebEnv :: DBStore -> ServiceConfig -> Waiters -> ReadHints -> [Provider] -> IO WebEnv
newWebEnv weStore weConfig weWaiters weHints weProviders = do
weBuckets <- newTVarIO Map.empty
newWebEnv :: DBStore -> ServiceConfig -> Waiters -> ReadHints -> [Provider] -> Maybe NamesEnv -> IO WebEnv
newWebEnv weStore weConfig weWaiters weHints weProviders weNames = do
weHttp <- newHttpEnv (listener weConfig)
weShell <- prepareShell (listener weConfig) (stripe weConfig)
pure WebEnv {weStore, weConfig, weWaiters, weProviders, weHoldMicros = holdMicros, weHints, weBuckets, weShell}
pure WebEnv {weStore, weConfig, weWaiters, weProviders, weHoldMicros = holdMicros, weHints, weHttp, weShell, weNames}
listenerConfig :: WebEnv -> ListenerConfig
listenerConfig WebEnv {weConfig} = listener weConfig
type Respond = Response -> IO ResponseReceived
-- | Short enough to stay under the idle timeout of any proxy in front of us.
holdMicros :: Int
holdMicros = 30 * 1000000
readLimit :: Limit
readLimit = Limit {lmName = "read", lmPerMinute = 60}
createLimit :: Limit
createLimit = Limit {lmName = "create", lmPerMinute = 5}
maxBodyBytes :: Int
maxBodyBytes = 8192
maxWebhookBytes :: Int
maxWebhookBytes = 64 * 1024
sha256Bytes :: Int
sha256Bytes = 32
codeHashChars :: Int
codeHashChars = 43
data Limit = Limit {lmName :: Text, lmPerMinute :: Int}
deriving (Eq, Show)
data Bucket = Bucket {bkCount :: Int, bkStarted :: UTCTime}
limitWindow :: NominalDiffTime
limitWindow = 60
maxBuckets :: Int
maxBuckets = 8192
reclaim :: UTCTime -> Map Text Bucket -> Map Text Bucket
reclaim now buckets
| Map.size buckets < maxBuckets = buckets
| Map.size live * 2 <= maxBuckets = live
| otherwise = Map.fromList (drop (Map.size live `div` 2) (sortOn (bkCount . snd) (Map.toList live)))
where
live = Map.filter (fresh now) buckets
fresh :: UTCTime -> Bucket -> Bool
fresh now Bucket {bkStarted} = diffUTCTime now bkStarted < limitWindow
takeToken :: WebEnv -> Limit -> Text -> IO (Maybe Int)
takeToken WebEnv {weBuckets} Limit {lmName, lmPerMinute} client = do
now <- getCurrentTime
atomically $ do
buckets <- reclaim now <$> readTVar weBuckets
let key = lmName <> "\t" <> client
count bucket = writeTVar weBuckets (Map.insert key bucket buckets) >> pure Nothing
-- write the reclaimed map back here too, or a refused request redoes the filtering every time and the map stays at the limit
refuse seconds = writeTVar weBuckets buckets >> pure (Just seconds)
case Map.lookup key buckets of
Just bucket@Bucket {bkCount, bkStarted}
| fresh now bucket ->
if bkCount < lmPerMinute
then count bucket {bkCount = bkCount + 1}
else refuse (secondsLeft now bkStarted)
_ -> count Bucket {bkCount = 1, bkStarted = now}
where
secondsLeft :: UTCTime -> UTCTime -> Int
secondsLeft now started = max 1 (ceiling (limitWindow - diffUTCTime now started))
limited :: WebEnv -> Limit -> Request -> Respond -> IO ResponseReceived -> IO ResponseReceived
limited env limit req respond action =
takeToken env limit (clientKey env req) >>= \case
Nothing -> action
Just seconds -> respond (rateLimited seconds)
-- | Every line is joined first, so a second header line a caller adds cannot leave its own line the one we read.
clientKey :: WebEnv -> Request -> Text
clientKey env req = fromMaybe (peerText (remoteHost req)) forwarded
where
forwarded
| not (lTrustForwardedFor (listenerConfig env)) = Nothing
| otherwise = case reverse (concatMap entries (requestHeaders req)) of
(ip : _) | isIpAddress ip -> Just ip
_ -> Nothing
entries (name, raw)
| name /= "x-forwarded-for" = []
| otherwise = either (const []) (filter (not . T.null) . map T.strip . T.splitOn ",") (decodeUtf8' raw)
isIpAddress :: Text -> Bool
isIpAddress t = isIPv4 t || isIPv6 t
asciiDigit :: Char -> Bool
asciiDigit c = c >= '0' && c <= '9'
-- Data.Char.isDigit and isHexDigit accept Unicode digits, which are not addresses.
asciiHexDigit :: Char -> Bool
asciiHexDigit c = asciiDigit c || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
-- | A leading zero is refused, so "010.0.0.1" cannot be a second key for "10.0.0.1".
isIPv4 :: Text -> Bool
isIPv4 t = case T.splitOn "." t of
[a, b, c, d] -> all octet [a, b, c, d]
_ -> False
where
octet s =
not (T.null s)
&& T.length s <= 3
&& T.all asciiDigit s
&& (T.length s == 1 || T.head s /= '0')
&& maybe False (<= 255) (readMaybe (T.unpack s) :: Maybe Int)
isIPv6 :: Text -> Bool
isIPv6 t
| T.any (== '%') t = False
| otherwise = case T.splitOn "::" t of
[full] -> groupCount True (segments full) == Just 8
[before, after] -> case (groupCount False (segments before), groupCount True (segments after)) of
(Just b, Just a) -> b + a <= 7 -- "::" stands for at least one omitted group
_ -> False
_ -> False
where
segments s = if T.null s then [] else T.splitOn ":" s
groupCount :: Bool -> [Text] -> Maybe Int
groupCount endsAddress gs
| null gs = Just 0
| endsAddress && isIPv4 (last gs) = (+ 2) <$> plainGroups (init gs)
| otherwise = plainGroups gs
where
plainGroups ps = if all hexGroup ps then Just (length ps) else Nothing
hexGroup g = not (T.null g) && T.length g <= 4 && T.all asciiHexDigit g
peerText :: SockAddr -> Text
peerText = \case
SockAddrInet _ addr ->
let (a, b, c, d) = hostAddressToTuple addr
in T.intercalate "." (map tshowInt [a, b, c, d])
SockAddrInet6 _ _ addr _ ->
let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple addr
in T.intercalate ":" (map hex16 [a, b, c, d, e, f, g, h])
SockAddrUnix p -> "unix:" <> T.pack p
where
tshowInt :: Show a => a -> Text
tshowInt = T.pack . show
hex16 :: Word16 -> Text
hex16 w = T.pack (showHex w "")
apiHeaders :: [Header]
apiHeaders = [(hContentType, "application/json"), (hCacheControl, "no-store")]
jsonResponse :: Status -> J.Value -> Response
jsonResponse st = responseLBS st apiHeaders . J.encode
jsonError :: Status -> Text -> Response
jsonError st code = jsonResponse st (J.object ["error" .= code])
notFound :: Response
notFound = jsonError status404 "not_found"
badRequest :: Response
badRequest = jsonError status400 "bad_request"
catalogChanged :: Response
catalogChanged = jsonError status400 "catalog_changed"
codeConflict :: Response
codeConflict = jsonError status409 "code_conflict"
providerUnavailable :: Response
providerUnavailable = jsonError status503 "provider_unavailable"
rateLimited :: Int -> Response
rateLimited seconds =
responseLBS
status429
(("Retry-After", B.pack (show seconds)) : apiHeaders)
(J.encode (J.object ["error" .= ("rate_limited" :: Text)]))
methodNotAllowed :: ByteString -> Response
methodNotAllowed allow = responseLBS status405 (("Allow", allow) : apiHeaders) (J.encode (J.object ["error" .= ("method_not_allowed" :: Text)]))
internalError :: Response
internalError = jsonError status500 "internal"
webhookOk, webhookRefused, webhookTooLarge :: Response
webhookOk = webhookResponse status200
webhookRefused = webhookResponse status400
@@ -274,23 +91,21 @@ webhookResponse :: Status -> Response
webhookResponse st = responseLBS st [(hCacheControl, "no-store")] ""
webApp :: WebEnv -> Application
webApp env req respond = case pathInfo req of
[] | serving -> only "GET" $ case weShell env of
webApp env@WebEnv {weHttp} req respond = case pathInfo req of
[] | serving -> only "GET" req respond $ case weShell env of
Just shell -> respond (responseLBS status200 shellHeaders shell)
Nothing -> serveStatic env ["index.html"] respond
"assets" : rest | serving -> only "GET" $ serveStatic env ("assets" : rest) respond
["sw.js"] | serving -> only "GET" $ serveStatic env ["sw.js"] respond
["api", "invoice"] -> only "POST" $ limited env createLimit req respond $ createInvoiceHandler env req respond
["api", "invoice", iid] -> only "GET" $ limited env readLimit req respond $ readInvoiceHandler env (InvoiceId iid) req respond
["api", "invoice", iid, "cancel"] -> only "POST" $ limited env createLimit req respond $ cancelInvoiceHandler env (InvoiceId iid) respond
["webhooks", "btcpay"] -> only "POST" $ webhookHandler env PPCrypto "POST /webhooks/btcpay" req respond
["webhooks", "stripe"] -> only "POST" $ webhookHandler env PPStripe "POST /webhooks/stripe" req respond
_ -> respond notFound
"assets" : rest | serving -> only "GET" req respond $ serveStatic env ("assets" : rest) respond
["sw.js"] | serving -> only "GET" req respond $ serveStatic env ["sw.js"] respond
["api", "invoice"] -> only "POST" req respond $ limited weHttp createLimit req respond $ createInvoiceHandler env req respond
["api", "invoice", iid] -> only "GET" req respond $ limited weHttp readLimit req respond $ readInvoiceHandler env (InvoiceId iid) req respond
["api", "invoice", iid, "cancel"] -> only "POST" req respond $ limited weHttp createLimit req respond $ cancelInvoiceHandler env (InvoiceId iid) respond
["webhooks", "btcpay"] -> only "POST" req respond $ webhookHandler env PPCrypto "POST /webhooks/btcpay" req respond
["webhooks", "stripe"] -> only "POST" req respond $ webhookHandler env PPStripe "POST /webhooks/stripe" req respond
_ -> fromMaybe (respond notFound) names
where
serving = lServeWebapp (listenerConfig env)
only method action
| requestMethod req == method = action
| otherwise = respond (methodNotAllowed method)
names = weNames env >>= \n -> namesApp weHttp (namesRoutes n) req respond
-- | A @".."@ check would miss separators WAI has already decoded, an absolute path, and a symlink out of the tree.
serveStatic :: WebEnv -> [Text] -> Respond -> IO ResponseReceived
@@ -610,19 +425,6 @@ providerOf = \case
SPMCard CPStripe -> PPStripe
SPMCrypto _ -> PPCrypto
-- | Returns the bytes exactly as they arrived, since the webhook signature is over those bytes.
readBoundedBody :: Int -> Request -> IO (Maybe LB.ByteString)
readBoundedBody cap req = go 0 []
where
go :: Int -> [ByteString] -> IO (Maybe LB.ByteString)
go read' acc = do
chunk <- getRequestBodyChunk req
if BS.null chunk
then pure (Just (LB.fromChunks (reverse acc)))
else
let read'' = read' + BS.length chunk
in if read'' > cap then pure Nothing else go read'' (chunk : acc)
parseCreateRequest :: LB.ByteString -> Maybe CreateRequest
parseCreateRequest body = case J.decode body of
Just (J.Object o) -> do
@@ -649,17 +451,6 @@ methodFromText = \case
"xmr" -> Just (SPMCrypto CCXmr)
_ -> Nothing
-- | The last character of a 43-character base64 string has two bits no digest byte uses, so a lax
-- decoder would accept four spellings of the same digest, which the re-encode check rejects.
parseCodeHash :: Text -> Maybe ByteString
parseCodeHash t
| T.length t /= codeHashChars = Nothing
| otherwise = case B64U.decode (encodeUtf8 (t <> "=")) of
Right bytes | BS.length bytes == sha256Bytes, canonical bytes == t -> Just bytes
_ -> Nothing
where
canonical = T.filter (/= '=') . safeDecodeUtf8 . B64U.encode
-- | A provider retries a delivery it sees fail, so anything thrown below is caught and answered 200 anyway.
webhookHandler :: WebEnv -> PaymentProvider -> Text -> Request -> Respond -> IO ResponseReceived
webhookHandler env@WebEnv {weStore, weHints} provider route req respond =
@@ -15,6 +15,11 @@ serve_webapp = on
; proxy's own address, so one bucket is shared by everyone. Set it off only where this
; listener is reached directly, since a client can write the header itself.
trust_forwarded_for = on
; the names site (simplex.domains): its build directory, the directory it is exported to for a
; reverse proxy, and the comma-separated hosts it is served on in the all-in-one deployment
; names_static_dir = /srv/names
; names_export_dir = /srv/names-export
; names_hosts = simplex.domains
; omit this whole section to disable Bitcoin and Monero (the browser shows the provider-unavailable screen)
[btcpay]
@@ -60,3 +65,34 @@ idle_seconds = 60
;[group]
;display_name = SimpleX Badges
;description = Welcome to the badges group
;
; the names store: omit [names] and [chain] together to turn it off.
;[names]
; testing until the .simplex TLD is deployed
;tld = testing
; the paid years of a name; at least 2, the controller's minimum
;years = 2
; a name's term starts here; the days until then are free
;term_start = 2027-01-01T00:00:00Z
; how far an order's duration may be from the one the service computes
;term_slack_seconds = 3600
; a commitment with less than this left before its reveal deadline is not reused
;reveal_margin_seconds = 900
; how many times one order may move to another name
;max_order_moves = 3
;
; the chain the names are registered on. rpc_url can carry a provider's API key and service_key
; pays for every commit and register: keep both secret. Neither is ever logged.
;[chain]
;rpc_url = http://127.0.0.1:8545
;chain_id = 31337
;controller = 0x...
;sponsor = 0x...
;base_registrar = 0x...
;resolver = 0x...
; 0x and 64 hex digits: the registrar key the service sends transactions with
;service_key = 0x...
; blocks after a register's receipt before a name counts as registered
;confirmations = 3
; the most gas any one transaction may use
;gas_limit_cap = 8000000
+13
View File
@@ -442,6 +442,11 @@ executable simplex-store-service
StoreService.Group
StoreService.Group.Command
StoreService.Log
StoreService.Names.Chain
StoreService.Names.Env
StoreService.Names.Phases
StoreService.Names.Registration
StoreService.Names.Turns
StoreService.Options
StoreService.Orders
StoreService.Poller
@@ -453,6 +458,8 @@ executable simplex-store-service
StoreService.Store.Invoices
StoreService.Store.Migrate
StoreService.Waiters
StoreService.Web.Http
StoreService.Web.Names
StoreService.Web.Server
Paths_simplex_chat
ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts
@@ -738,6 +745,7 @@ test-suite simplex-chat-test
Bots.StoreService.GroupIntegrationTests
Bots.StoreService.GroupTests
Bots.StoreService.NameRegistrationTests
Bots.StoreService.NamesWiringTests
Bots.StoreService.StripeTests
Bots.StoreService.WaitersTests
Bots.StoreService.WebTests
@@ -760,7 +768,10 @@ test-suite simplex-chat-test
StoreService.Group.Command
StoreService.Log
StoreService.Names.Chain
StoreService.Names.Env
StoreService.Names.Phases
StoreService.Names.Registration
StoreService.Names.Turns
StoreService.Options
StoreService.Orders
StoreService.Poller
@@ -772,6 +783,8 @@ test-suite simplex-chat-test
StoreService.Store.Invoices
StoreService.Store.Migrate
StoreService.Waiters
StoreService.Web.Http
StoreService.Web.Names
StoreService.Web.Server
Paths_simplex_chat
if flag(client_postgres)
+188
View File
@@ -4,12 +4,18 @@
module Bots.StoreService.ConfigTests where
import StoreService.Config
import qualified Data.ByteArray as BA
import qualified Data.ByteString.Char8 as B
import Data.Either (isLeft)
import Data.Ini (readIniFile)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..))
import Simplex.Messaging.Crypto.Secp256k1 (mkPrivateKey)
import Simplex.Messaging.Encoding.String (strDecode)
import Simplex.Messaging.Eth.Address (Address)
import Simplex.Messaging.SimplexName (SimplexTLD (..))
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import Test.Hspec
@@ -408,3 +414,185 @@ testGroupAbsent :: IO ()
testGroupAbsent = do
r <- parseIni listenerIni
fmap group r `shouldBe` Right Nothing
namesConfigTests :: Spec
namesConfigTests = describe "StoreService names: config" $ do
it "reads [names] and [chain], applying every documented default" testNamesDefaults
it "reads every [names] and [chain] setting written out" testNamesWrittenOut
it "reads the names keys of [listener]" testNamesListenerKeys
it "leaves the names side off when [names] is absent" testNamesAbsent
it "refuses [names] without [chain], and [chain] without [names]" testNamesNeedsChain
it "refuses years below 2" testNamesYearsBelowTwo
it "refuses a term_start that is not an ISO 8601 UTC time" testNamesBadTermStart
it "refuses a TLD other than simplex or testing" testNamesBadTLD
it "refuses a bad or wrongly checksummed contract address, naming the key" testNamesBadAddress
it "refuses a bad service key without quoting it" testNamesBadServiceKey
it "keeps the RPC URL and the service key out of errors and the shown config" testNamesSecretsHidden
it "names a [names] or [chain] setting nothing reads, and still boots" testNamesUnknownKeys
rpcUrl :: T.Text
rpcUrl = "https://eth.example.org/v2/rpc-token-value"
-- anvil's first dev account, a real secp256k1 key
serviceKeyHex :: T.Text
serviceKeyHex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
controllerAddr, sponsorAddr, registrarAddr, resolverAddr :: T.Text
controllerAddr = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
sponsorAddr = "0x0B306BF915C4d645ff596e518fAf3F9669b97016"
registrarAddr = "0x014f9c09CcEeDEF1B713110d04D3a07d5B5D9b73"
resolverAddr = "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"
namesSection :: [T.Text]
namesSection = ["[names]", "tld = testing"]
chainSection :: [T.Text]
chainSection =
[ "[chain]",
"rpc_url = " <> rpcUrl,
"chain_id = 31337",
"controller = " <> controllerAddr,
"sponsor = " <> sponsorAddr,
"base_registrar = " <> registrarAddr,
"resolver = " <> resolverAddr,
"service_key = " <> serviceKeyHex
]
namesIni :: [T.Text] -> [T.Text] -> T.Text
namesIni ns cs = fullIni <> T.unlines (ns <> cs)
namesRefusal :: [T.Text] -> [T.Text] -> IO String
namesRefusal ns cs =
parseIni (namesIni ns cs) >>= \r -> case r of
Left e -> pure e
Right cfg -> expectationFailure ("must not boot, it read " <> show (names cfg)) >> pure ""
parsedNames :: [T.Text] -> [T.Text] -> IO NamesConfig
parsedNames ns cs =
parseIni (namesIni ns cs) >>= \r -> case r of
Right cfg -> maybe (fail "[names] was present but not read") pure (names cfg)
Left e -> fail ("must boot, but: " <> e)
address :: HasCallStack => T.Text -> Address
address t = either error id (strDecode (B.pack (T.unpack t)))
testNamesDefaults :: IO ()
testNamesDefaults = do
NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nChain} <- parsedNames namesSection chainSection
nTLD `shouldBe` TLDTesting
nYears `shouldBe` 2
nTermStart `shouldBe` UTCTime (fromGregorian 2027 1 1) 0
nTermSlackSeconds `shouldBe` 3600
nRevealMarginSeconds `shouldBe` 900
nMaxOrderMoves `shouldBe` 3
let ChainConfig {cRpcUrl, cChainId, cController, cSponsor, cBaseRegistrar, cResolver, cServiceKey, cConfirmations, cGasLimitCap} = nChain
cRpcUrl `shouldBe` T.unpack rpcUrl
cChainId `shouldBe` 31337
(cController, cSponsor, cBaseRegistrar, cResolver) `shouldBe` (address controllerAddr, address sponsorAddr, address registrarAddr, address resolverAddr)
Right expectedKey <- pure (mkPrivateKey (BA.pack [0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfc, 0xae, 0x78, 0x4d, 0x7b, 0xf4, 0xf2, 0xff, 0x80]))
(cServiceKey == expectedKey) `shouldBe` True
cConfirmations `shouldBe` 3
cGasLimitCap `shouldBe` 8000000
testNamesWrittenOut :: IO ()
testNamesWrittenOut = do
let ns = ["[names]", "tld = simplex", "years = 5", "term_start = 2027-03-01T12:00:00Z", "term_slack_seconds = 60", "reveal_margin_seconds = 0", "max_order_moves = 0"]
cs = chainSection <> ["confirmations = 0", "gas_limit_cap = 500000"]
NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nChain = ChainConfig {cConfirmations, cGasLimitCap}} <- parsedNames ns cs
nTLD `shouldBe` TLDSimplex
nYears `shouldBe` 5
nTermStart `shouldBe` UTCTime (fromGregorian 2027 3 1) (12 * 3600)
(nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves) `shouldBe` (60, 0, 0)
(cConfirmations, cGasLimitCap) `shouldBe` (0, 500000)
namesRefusal ns (chainSection <> ["confirmations = -1"]) `shouldReturn` "chain.confirmations must be at least 0"
namesRefusal ns (chainSection <> ["gas_limit_cap = 0"]) `shouldReturn` "chain.gas_limit_cap must be a positive whole number"
namesRefusal ns (filter (not . ("chain_id" `T.isPrefixOf`)) chainSection) `shouldReturn` "chain.chain_id is required"
namesRefusal (namesSection <> ["max_order_moves = -1"]) chainSection `shouldReturn` "names.max_order_moves must be at least 0"
testNamesListenerKeys :: IO ()
testNamesListenerKeys = do
let withKeys = T.replace "static_dir = /srv/badges\n" "static_dir = /srv/badges\nnames_static_dir = /srv/names\nnames_export_dir = /srv/names-out\nnames_hosts = simplex.domains, WWW.simplex.domains ,\n" fullIni
Right cfg <- parseIni withKeys
let ListenerConfig {lNamesStaticDir, lNamesExportDir, lNamesHosts} = listener cfg
lNamesStaticDir `shouldBe` Just "/srv/names"
lNamesExportDir `shouldBe` Just "/srv/names-out"
lNamesHosts `shouldBe` ["simplex.domains", "www.simplex.domains"]
Right plain <- parseIni fullIni
let ListenerConfig {lNamesStaticDir = noDir, lNamesExportDir = noExport, lNamesHosts = noHosts} = listener plain
(noDir, noExport, noHosts) `shouldBe` (Nothing, Nothing, [])
testNamesAbsent :: IO ()
testNamesAbsent = do
Right cfg <- parseIni fullIni
names cfg `shouldBe` Nothing
testNamesNeedsChain :: IO ()
testNamesNeedsChain = do
namesRefusal namesSection [] `shouldReturn` "[names] needs a [chain] section"
namesRefusal [] chainSection `shouldReturn` "[chain] is set but [names] is absent, so nothing would use it"
testNamesYearsBelowTwo :: IO ()
testNamesYearsBelowTwo = do
mapM_ (\v -> namesRefusal (namesSection <> ["years = " <> v]) chainSection `shouldReturn` "names.years must be at least 2") ["1", "0", "-2"]
NamesConfig {nYears} <- parsedNames (namesSection <> ["years = 2"]) chainSection
nYears `shouldBe` 2
testNamesBadTermStart :: IO ()
testNamesBadTermStart =
mapM_
(\v -> namesRefusal (namesSection <> ["term_start = " <> v]) chainSection >>= (`shouldContain` "names.term_start must be an ISO 8601 UTC time"))
["2027-01-01", "2027-01-01T00:00:00", "2027-13-01T00:00:00Z", "1 January 2027"]
testNamesBadTLD :: IO ()
testNamesBadTLD = do
namesRefusal ["[names]", "tld = simplex.chat"] chainSection `shouldReturn` "names.tld must be simplex or testing, not simplex.chat"
namesRefusal ["[names]", "years = 2"] chainSection `shouldReturn` "names.tld is required"
testNamesBadAddress :: IO ()
testNamesBadAddress = do
let withSponsor v = map (\l -> if "sponsor =" `T.isPrefixOf` l then "sponsor = " <> v else l) chainSection
refused v = namesRefusal namesSection (withSponsor v) `shouldReturn` "chain.sponsor is not an Ethereum address"
-- the mixed-case form of sponsorAddr with one letter's case flipped fails its EIP-55 checksum
refused "0x0b306BF915C4d645ff596e518fAf3F9669b97016"
refused "0x0B306BF915C4d645ff596e518fAf3F9669b970"
refused "0x0B306BF915C4d645ff596e518fAf3F9669b97016ff"
refused "not-an-address"
NamesConfig {nChain = ChainConfig {cSponsor}} <- parsedNames namesSection (withSponsor (T.toLower sponsorAddr))
cSponsor `shouldBe` address sponsorAddr
testNamesBadServiceKey :: IO ()
testNamesBadServiceKey = do
let withKey v = map (\l -> if "service_key =" `T.isPrefixOf` l then "service_key = " <> v else l) chainSection
refused v = do
e <- namesRefusal namesSection (withKey v)
e `shouldBe` "chain.service_key must be 0x and 64 hex digits of a valid secp256k1 key"
refused (T.drop 2 serviceKeyHex)
refused (T.dropEnd 2 serviceKeyHex)
refused ("0x" <> T.replicate 64 "0")
-- the curve order n is not a valid key
refused "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"
refused "0xzz0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
testNamesSecretsHidden :: IO ()
testNamesSecretsHidden = do
let secretUrl = "ftp://eth.example.org/v2/rpc-token-value"
withUrl v = map (\l -> if "rpc_url =" `T.isPrefixOf` l then "rpc_url = " <> v else l) chainSection
e <- namesRefusal namesSection (withUrl secretUrl)
e `shouldBe` "chain.rpc_url must be an http or https URL"
e `shouldNotContain` "rpc-token-value"
other <- namesRefusal namesSection (chainSection <> ["confirmations = many"])
other `shouldNotContain` "rpc-token-value"
other `shouldNotContain` T.unpack (T.drop 2 serviceKeyHex)
Right cfg <- parseIni (namesIni namesSection chainSection)
show cfg `shouldNotContain` "rpc-token-value"
show cfg `shouldNotContain` T.unpack (T.drop 2 serviceKeyHex)
show cfg `shouldContain` "chain 31337"
testNamesUnknownKeys :: IO ()
testNamesUnknownKeys = do
let t = namesIni (namesSection <> ["yeers = 3"]) (chainSection <> ["rpc = x"])
withIni t $ \p -> do
Right ini <- readIniFile p
unknownKeys ini `shouldMatchList` ["names.yeers", "chain.rpc"]
NamesConfig {nYears} <- parsedNames (namesSection <> ["yeers = 3"]) (chainSection <> ["rpc = x"])
nYears `shouldBe` 2
+225
View File
@@ -0,0 +1,225 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module Bots.StoreService.NamesWiringTests (namesWiringTests) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, concurrently_, replicateConcurrently_, wait)
import qualified Control.Concurrent.Async as Async
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM
import qualified Control.Exception as E
import Control.Monad (replicateM_)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy as LB
import Data.Maybe (fromMaybe, isNothing)
import Network.HTTP.Client (Manager, Response, defaultManagerSettings, httpLbs, newManager, parseRequest, responseBody, responseHeaders, responseStatus)
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types (Method, hCacheControl, hContentType, status200)
import Network.HTTP.Types.Status (statusCode)
import qualified Network.Wai.Handler.Warp as Warp
import Simplex.Messaging.SimplexName (labelHash)
import StoreService.Config (ListenerConfig (..))
import StoreService.Names.Phases
import StoreService.Names.Turns
import StoreService.Web.Http
import StoreService.Web.Names (Route (..), namesApp)
import System.Timeout (timeout)
import Test.Hspec
namesWiringTests :: Spec
namesWiringTests = describe "StoreService names: wiring" $ do
describe "phases" $ do
it "wakes a waiter on a newer published version, not on an older or the same one" testPublishWakesOnNewer
it "answers at once when the version read is not the one seen" testVersionAlreadyMoved
it "catches a publish that lands between subscribing and reading" testPublishBeforeRead
it "gives up after the hold when nothing is published" testHoldTimesOut
it "is not woken by another registration's publish" testOtherRegistration
describe "router" $ do
it "answers a known path with another method 405 with Allow, spending no token" testWrongMethod
it "leaves an unknown path to the caller" testUnknownPath
describe "turns" $ do
it "runs two holders of one label hash one after the other" testOneLabelSerialized
it "runs holders of different label hashes at once" testDifferentLabelsConcurrent
it "runs two holders of one code hash one after the other" testOneCodeSerialized
it "holds both label hashes in withLabelTurns, and one when they are equal" testLabelTurnsHoldBoth
it "never deadlocks withLabelTurns A->B against B->A" testLabelTurnsNoDeadlock
it "releases a turn whose holder throws" testTurnReleasedOnThrow
-- | Long enough that only a missed wake-up could make a test wait it out.
longHold :: Int
longHold = 10000000
-- | How long a test waits for something that must happen promptly.
prompt :: Int
prompt = 2000000
parkDelay :: Int
parkDelay = 100000
rid1, rid2 :: RegistrationId
rid1 = RegistrationId "reg-1"
rid2 = RegistrationId "reg-2"
testPublishWakesOnNewer :: IO ()
testPublishWakesOnNewer = do
phases <- newPhases
parked <- newEmptyMVar
waiter <- async $ awaitChange phases rid1 (putMVar parked () >> pure 3) 3 longHold
takeMVar parked
atomically $ publish phases rid1 2
atomically $ publish phases rid1 3
threadDelay parkDelay
Async.poll waiter >>= (`shouldSatisfy` isNothing)
atomically $ publish phases rid1 4
timeout prompt (wait waiter) `shouldReturn` Just True
testVersionAlreadyMoved :: IO ()
testVersionAlreadyMoved = do
phases <- newPhases
timeout prompt (awaitChange phases rid1 (pure 5) 3 longHold) `shouldReturn` Just True
testPublishBeforeRead :: IO ()
testPublishBeforeRead = do
phases <- newPhases
-- the row read here is the old one, though version 4 was committed and published while it ran
let staleRead = atomically (publish phases rid1 4) >> pure 3
timeout prompt (awaitChange phases rid1 staleRead 3 longHold) `shouldReturn` Just True
testHoldTimesOut :: IO ()
testHoldTimesOut = do
phases <- newPhases
timeout prompt (awaitChange phases rid1 (pure 3) 3 parkDelay) `shouldReturn` Just False
testOtherRegistration :: IO ()
testOtherRegistration = do
phases <- newPhases
parked <- newEmptyMVar
waiter <- async $ awaitChange phases rid1 (putMVar parked () >> pure 1) 1 (3 * parkDelay)
takeMVar parked
atomically $ publish phases rid2 7
timeout prompt (wait waiter) `shouldReturn` Just False
type Client = (Manager, String)
probeLimit :: Limit
probeLimit = Limit {lmName = "probe", lmPerMinute = 1}
withProbeRouter :: (Client -> IO a) -> IO a
withProbeRouter action = do
http <- newHttpEnv ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = "", lServeWebapp = False, lWebappExportDir = Nothing, lTrustForwardedFor = False, lNamesStaticDir = Nothing, lNamesExportDir = Nothing, lNamesHosts = []}
let routes = \case
["api", "probe", _] -> Just Route {rMethod = "GET", rLimit = probeLimit, rHandler = \_ respond -> respond (jsonResponse status200 (J.object []))}
_ -> Nothing
app req respond = fromMaybe (respond notFound) (namesApp http routes req respond)
mgr <- newManager defaultManagerSettings
Warp.testWithApplication (pure app) $ \port -> action (mgr, "http://127.0.0.1:" <> show port)
send :: Client -> Method -> String -> IO (Response LB.ByteString)
send (mgr, base) verb target = do
req <- parseRequest (base <> target)
httpLbs req {HTTP.method = verb} mgr
errorTag :: Response LB.ByteString -> Maybe J.Value
errorTag r = case J.decode (responseBody r) of
Just (J.Object o) -> KM.lookup "error" o
_ -> Nothing
testWrongMethod :: IO ()
testWrongMethod = withProbeRouter $ \client -> do
replicateM_ 2 $ do
r <- send client "POST" "/api/probe/x"
statusCode (responseStatus r) `shouldBe` 405
lookup "Allow" (responseHeaders r) `shouldBe` Just "GET"
lookup hContentType (responseHeaders r) `shouldBe` Just "application/json"
lookup hCacheControl (responseHeaders r) `shouldBe` Just "no-store"
errorTag r `shouldBe` Just (J.String "method_not_allowed")
allowed <- send client "GET" "/api/probe/x"
statusCode (responseStatus allowed) `shouldBe` 200
limitedR <- send client "GET" "/api/probe/x"
statusCode (responseStatus limitedR) `shouldBe` 429
errorTag limitedR `shouldBe` Just (J.String "rate_limited")
lookup "Retry-After" (responseHeaders limitedR) `shouldSatisfy` maybe False (BC.all (`elem` ['0' .. '9']))
testUnknownPath :: IO ()
testUnknownPath = withProbeRouter $ \client -> do
r <- send client "POST" "/api/probe"
statusCode (responseStatus r) `shouldBe` 404
errorTag r `shouldBe` Just (J.String "not_found")
-- | Runs the holders concurrently and returns the most that were inside at once.
mostInside :: Int -> ((IO () -> IO ()) -> IO ()) -> IO Int
mostInside n holder = do
inside <- newTVarIO (0 :: Int)
most <- newTVarIO 0
let enter = atomically $ do
k <- (+ 1) <$> readTVar inside
writeTVar inside k
modifyTVar' most (max k)
leave = atomically $ modifyTVar' inside (subtract 1)
replicateConcurrently_ n $ holder (\work -> enter >> work >> leave)
readTVarIO most
testOneLabelSerialized :: IO ()
testOneLabelSerialized = do
turns <- newTurns
r <- timeout (5 * prompt) $ mostInside 8 $ \inTurn -> withLabelTurn turns (labelHash "alice1") (inTurn (threadDelay 20000))
r `shouldBe` Just 1
testOneCodeSerialized :: IO ()
testOneCodeSerialized = do
turns <- newTurns
r <- timeout (5 * prompt) $ mostInside 8 $ \inTurn -> withCodeTurn turns (BC.replicate 32 'c') (inTurn (threadDelay 20000))
r `shouldBe` Just 1
-- | Each holder waits inside its turn for the other to be inside its own, which only concurrent turns allow.
testDifferentLabelsConcurrent :: IO ()
testDifferentLabelsConcurrent = do
turns <- newTurns
aInside <- newEmptyMVar
bInside <- newEmptyMVar
r <-
timeout prompt $
concurrently_
(withLabelTurn turns (labelHash "alice1") (putMVar aInside () >> takeMVar bInside))
(withLabelTurn turns (labelHash "bobby2") (putMVar bInside () >> takeMVar aInside))
r `shouldBe` Just ()
testLabelTurnsHoldBoth :: IO ()
testLabelTurnsHoldBoth = do
turns <- newTurns
let a = labelHash "alice1"
b = labelHash "bobby2"
withLabelTurns turns a b $ do
timeout parkDelay (withLabelTurn turns a (pure ())) `shouldReturn` Nothing
timeout parkDelay (withLabelTurn turns b (pure ())) `shouldReturn` Nothing
timeout prompt (withLabelTurns turns a a (pure ())) `shouldReturn` Just ()
testLabelTurnsNoDeadlock :: IO ()
testLabelTurnsNoDeadlock = do
turns <- newTurns
let a = labelHash "alice1"
b = labelHash "bobby2"
rounds = 200
r <-
timeout (5 * prompt) $
concurrently_
(replicateConcurrently_ rounds (withLabelTurns turns a b (threadDelay 50)))
(replicateConcurrently_ rounds (withLabelTurns turns b a (threadDelay 50)))
r `shouldBe` Just ()
data Boom = Boom
deriving (Show)
instance E.Exception Boom
testTurnReleasedOnThrow :: IO ()
testTurnReleasedOnThrow = do
turns <- newTurns
let a = labelHash "alice1"
E.try (withLabelTurn turns a (E.throwIO Boom)) >>= \case
Left Boom -> pure ()
Right () -> expectationFailure "the holder's exception must reach the caller"
timeout prompt (withLabelTurn turns a (pure ())) `shouldReturn` Just ()
+14 -12
View File
@@ -17,6 +17,7 @@ import StoreService.Providers.Stripe (stripeProvider)
import StoreService.Store (KeyPurchase (..), KeyRedemption (..), ManagedGroup (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getCodePurchaseForKey, getManagedGroup, insertBadgeCode, insertManagedGroup, markOwnerBootstrapped, revokeCode)
import StoreService.Store.Invoices
import StoreService.Waiters (awaitStatus, newWaiters, publish, waitingCount)
import StoreService.Web.Http
import StoreService.Web.Server
import Bots.StoreService.BotTests (codeCounts, newPurchaseKeys)
import Bots.StoreService.CatalogTests (WebOffer (..), WebPrice (..), parseCatalogSource)
@@ -974,12 +975,13 @@ prepareStaticDir root = do
testServiceConfig :: FilePath -> Bool -> ServiceConfig
testServiceConfig staticDir trustForwarded =
ServiceConfig
{ listener = ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = staticDir, lServeWebapp = True, lWebappExportDir = Nothing, lTrustForwardedFor = trustForwarded},
{ listener = ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = staticDir, lServeWebapp = True, lWebappExportDir = Nothing, lTrustForwardedFor = trustForwarded, lNamesStaticDir = Nothing, lNamesExportDir = Nothing, lNamesHosts = []},
btcpay = Nothing,
stripe = Nothing,
poll = PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60},
issuer = Nothing,
group = Nothing
group = Nothing,
names = Nothing
}
testServeWebappOff :: IO ()
@@ -1003,7 +1005,7 @@ testExportWebapp = do
withTempDirectory "tests/tmp" "badge-export" $ \root -> do
staticDir <- prepareStaticDir root
let outDir = root </> "out"
lc = ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = staticDir, lServeWebapp = False, lWebappExportDir = Just outDir, lTrustForwardedFor = False}
lc = ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = staticDir, lServeWebapp = False, lWebappExportDir = Just outDir, lTrustForwardedFor = False, lNamesStaticDir = Nothing, lNamesExportDir = Nothing, lNamesHosts = []}
stripeCfg = Just StripeConfig {sSecretKey = "rk_test_x", sPublishableKey = "pk_test_injected", sWebhookSecret = "whsec_x", sSessionMinutes = 60, sHost = "https://api.stripe.com"}
exportWebapp lc stripeCfg
idx <- LB.readFile (outDir </> "index.html")
@@ -1038,7 +1040,7 @@ withListener providers rethrow hold st cfg action = do
let runner = if rethrow then Warp.testWithApplicationSettings else Warp.withApplicationSettings
waiters <- newWaiters
hints <- newReadHints
built <- newWebEnv st cfg waiters hints providers
built <- newWebEnv st cfg waiters hints providers Nothing
let env = built {weHoldMicros = hold}
mgr <- newManager defaultManagerSettings
runner (webSettings (listener cfg)) (pure (webApp env)) $ \prt ->
@@ -1433,23 +1435,23 @@ testForwardedForMustBeAnAddress = bounded "forwarded-for is an address" $ withWe
testBucketsStayBounded :: IO ()
testBucketsStayBounded = bounded "bucket cap" $ withWebApp $ \env _ -> do
let clients = map (\i -> T.pack ("198.51." <> show (i `div` 256) <> "." <> show (i `mod` 256))) [1 .. maxBuckets * 2 + 100]
mapM_ (takeToken env readLimit) clients
held <- Map.size <$> readTVarIO (weBuckets env)
mapM_ (takeToken (weHttp env) readLimit) clients
held <- Map.size <$> readTVarIO (heBuckets (weHttp env))
held `shouldSatisfy` \n -> n > 0 && n <= maxBuckets
answers <- mapM (\_ -> takeToken env readLimit "203.0.113.9") [1 .. readsPerMinute + 1]
answers <- mapM (\_ -> takeToken (weHttp env) readLimit "203.0.113.9") [1 .. readsPerMinute + 1]
length (filter isJust answers) `shouldBe` 1
let fill i = do
n <- Map.size <$> readTVarIO (weBuckets env)
n <- Map.size <$> readTVarIO (heBuckets (weHttp env))
when (n < maxBuckets) $ do
_ <- takeToken env readLimit (T.pack ("192.0." <> show (i `div` 256) <> "." <> show (i `mod` 256)))
_ <- takeToken (weHttp env) readLimit (T.pack ("192.0." <> show (i `div` 256) <> "." <> show (i `mod` 256)))
fill (i + 1)
fill (1 :: Int)
atTheCap <- Map.size <$> readTVarIO (weBuckets env)
atTheCap <- Map.size <$> readTVarIO (heBuckets (weHttp env))
atTheCap `shouldBe` maxBuckets
refusedAgain <- takeToken env readLimit "203.0.113.9"
refusedAgain <- takeToken (weHttp env) readLimit "203.0.113.9"
refusedAgain `shouldSatisfy` isJust
afterARefusal <- Map.size <$> readTVarIO (weBuckets env)
afterARefusal <- Map.size <$> readTVarIO (heBuckets (weHttp env))
afterARefusal `shouldSatisfy` (<= atTheCap `div` 2 + 1)
testHoldReportsTheRowNotTheWake :: IO ()
+3
View File
@@ -10,6 +10,7 @@ import Bots.StoreService.ConfigTests
import Bots.StoreService.GroupIntegrationTests
import Bots.StoreService.GroupTests
import Bots.StoreService.NameRegistrationTests
import Bots.StoreService.NamesWiringTests
import Bots.StoreService.StripeTests
import Bots.StoreService.WaitersTests
import Bots.StoreService.WebTests
@@ -86,6 +87,8 @@ main = do
badgeBTCPayTests
badgeStripeTests
badgeNameRegistrationTests
namesConfigTests
namesWiringTests
describe "SimpleX chat markdown" markdownTests
describe "JSON Tests" jsonTests
describe "Member relations" memberRelationsTests