keiro-ops-http-0.22.0.0: src/Keiro/Ops/Http/WebSocket.hs
-- | One socket writer and a multiplexed, bounded connection queue. Notifications
-- remain hints; all frames are produced by supported inspection reads.
module Keiro.Ops.Http.WebSocket (feedServerApp, feedsMiddleware, opsApplicationWithFeeds) where
import Control.Concurrent.Async (race_)
import Control.Concurrent.STM
import Control.Exception (bracket, catch, finally, mask, onException, throwIO)
import Control.Monad (forM_, void, when)
import Data.Aeson (object, (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString qualified as Bytes
import Data.ByteString.Char8 qualified as Char8
import Data.ByteString.Lazy qualified as Lazy
import Data.IORef
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text.Encoding qualified as Text
import Keiro.Ops (AppHooks)
import Keiro.Ops.Http.Application (opsApplicationWithBudget)
import Keiro.Ops.Http.Budget qualified as Budget
import Keiro.Ops.Http.Config
import Keiro.Ops.Http.Cors qualified as Cors
import Keiro.Ops.Http.Feed qualified as Feed
import Keiro.Ops.Http.Feed.Driver qualified as Driver
import Keiro.Ops.Http.Feed.Queue qualified as Queue
import Keiro.Ops.Http.Protocol qualified as P
import Keiro.Ops.Http.Views
import Kiroku.Store.Connection (KirokuStore)
import Network.Wai (Application, Middleware, pathInfo)
import Network.Wai.Handler.WebSockets qualified as WaiWS
import Network.WebSockets qualified as WS
import System.Timeout (timeout)
opsApplicationWithFeeds :: OpsHttpConfig -> Feed.FeedHandle -> AppHooks -> KirokuStore -> IO Application
opsApplicationWithFeeds config handle hooks store
| config /= Feed.handleHttpConfig handle = ioError (userError "HTTP and feeds must share the same inspection policy and budget")
| otherwise = feedsMiddleware config handle <$> opsApplicationWithBudget config (Feed.handleBudget handle) hooks store
feedsMiddleware :: OpsHttpConfig -> Feed.FeedHandle -> Middleware
feedsMiddleware config handle application request respond
| pathInfo request == ["ws"] = WaiWS.websocketsOr options (feedServerApp config handle) application request respond
| otherwise = application request respond
where
maximum = fromIntegral config.inspectionLimits.maxRequestBytes
options = WS.defaultConnectionOptions {WS.connectionFramePayloadSizeLimit = WS.SizeLimit maximum, WS.connectionMessageDataSizeLimit = WS.SizeLimit maximum}
feedServerApp :: OpsHttpConfig -> Feed.FeedHandle -> WS.ServerApp
feedServerApp config handle pending
| config /= Feed.handleHttpConfig handle = reject 500 "invalid_feed_policy" "HTTP and feeds have different host policies"
| Char8.takeWhile (/= '?') request.requestPath /= "/ws" = reject 404 "not_found" "no inspection socket at this path"
| Bytes.length request.requestPath > config.inspectionLimits.maxRequestBytes = reject 413 "inspection_request_too_large" "socket path exceeds the host limit"
| not (originAllowed config request) = reject 403 "origin_not_allowed" "socket origin is not allowed"
| otherwise = do
admitted <- Feed.withFeedConnection handle $ do
checked <- Budget.withInspectionBudget (Feed.handleBudget handle) config.inspectionLimits.readDeadlineSeconds (Feed.checkFeedSchema handle)
case checked of
Left Budget.InspectionBusy -> reject 503 "inspection_busy" "inspection read capacity is busy"
Left Budget.InspectionTimedOut -> reject 504 "inspection_timeout" "schema verification exceeded the read deadline"
Right Feed.FeedSchemaReadFailed -> reject 500 "schema_check_failed" "schema verification failed"
Right Feed.FeedSchemaDrift | config.schemaDrift == RefuseOnDrift -> reject 409 "schema_drift" "database schema does not match this binary"
Right _ -> do
closed <- atomically (Feed.handleClosed handle)
if closed
then reject 503 "feed_closed" "inspection feeds are closed"
else WS.acceptRequest pending >>= runConnection handle
case admitted of
Just () -> pure ()
Nothing -> reject 503 "too_many_connections" "socket connection capacity is full"
where
request = WS.pendingRequest pending
reject code reason message =
WS.rejectRequestWith
pending
WS.defaultRejectRequest
{ WS.rejectCode = code,
WS.rejectMessage = "Inspection request refused",
WS.rejectHeaders = [("Content-Type", "application/json")],
WS.rejectBody = Lazy.toStrict (Aeson.encode (object ["error" .= object ["code" .= (reason :: Text), "message" .= (message :: Text)]]))
}
originAllowed :: OpsHttpConfig -> WS.RequestHead -> Bool
originAllowed config request =
all (\(key, value) -> key /= "Origin" || Bytes.length value <= config.inspectionLimits.maxRequestBytes) request.requestHeaders
&& Cors.originAllowed config.allowedOrigins request.requestSecure request.requestHeaders
type Subscription = (View, Feed.Driver, Feed.Subscriber)
runConnection :: Feed.FeedHandle -> WS.Connection -> IO ()
runConnection handle connection = do
let config = Feed.handleFeedConfig handle
queue <- Queue.newOutboundQueue config.queueCapacity config.maxQueuedBytes config.maxFrameBytes
subscriptions <- newIORef Map.empty
let cleanup = do
atomically (Queue.closeQueue queue)
current <- atomicModifyIORef' subscriptions (\values -> (Map.empty, values))
forM_ (Map.elems current) $ \(view, driver, subscriber) -> do
Feed.unsubscribe driver subscriber
Feed.releaseDriver handle view
sendControl frame = do
prepared <- Queue.prepareOutgoing (min config.maxFrameBytes config.maxQueuedBytes) frame
case prepared of
Nothing -> throwIO (userError "socket control frame exceeds host limits")
Just outgoing -> void (atomically (Queue.enqueueOutgoing queue outgoing))
failure code message feed = sendControl (P.ErrorFrame code message feed)
receive = do
bytes <- WS.receiveData connection :: IO Lazy.ByteString
case Aeson.eitherDecode bytes of
Left _ -> failure P.InvalidFrame "invalid socket frame" Nothing
Right P.Ping -> sendControl P.Pong
Right (P.UnknownSubscription _) -> failure P.UnknownFeed "unknown feed" Nothing
Right (P.Unsubscribe name) -> mask $ \_ -> do
current <- readIORef subscriptions
forM_ (Map.lookup name current) $ \(view, driver, subscriber) -> do
atomically (Driver.unsubscribeSTM driver subscriber)
writeIORef subscriptions (Map.delete name current)
Feed.releaseDriver handle view
sendControl (P.Unsubscribed name)
Right (P.Subscribe name filters) -> case makeView name filters of
Left message -> failure P.InvalidSubscription message (Just name)
Right view -> replaceSubscription handle queue subscriptions name view failure
receive
send = do
again <-
bracket
( timeout
(config.idlePingSeconds * 1000000)
( atomically $ do
closed <- Feed.handleClosed handle
if closed then Queue.closeQueue queue >> pure Nothing else Queue.nextOutgoing queue
)
)
(\case Just (Just outgoing) -> atomically (Queue.finishOutgoing queue outgoing); _ -> pure ())
( \case
Nothing -> boundedSend (WS.sendPing connection ("" :: Bytes.ByteString)) >> pure True
Just Nothing -> do
boundedSend (WS.sendTextData connection (Aeson.encode P.Goodbye))
boundedSend (WS.sendClose connection ("inspection feeds closed" :: Text))
pure False
Just (Just outgoing) -> boundedSend (WS.sendTextData connection outgoing.bytes) >> pure True
)
when again send
boundedSend action =
timeout (ceiling (config.readTimeout * 1000000)) action
>>= maybe
(throwIO (userError "socket send deadline exceeded"))
pure
makeView = \case P.Workflows -> workflowsView config.maxItems; P.Timers -> timersView config.maxItems; P.ProjectionGroups -> projectionGroupsView config.maxItems
(race_ receive send `catch` (\(_ :: WS.ConnectionException) -> pure ())) `finally` cleanup
-- Acquiring a replacement does not disturb the old subscription on refusal.
-- The queue purge, detach and attach commit atomically so old-view deltas cannot
-- arrive after the new snapshot. Balanced registry releases cannot be cancelled.
replaceSubscription :: Feed.FeedHandle -> Queue.OutboundQueue -> IORef (Map.Map P.FeedName Subscription) -> P.FeedName -> View -> (P.SocketErrorCode -> Text -> Maybe P.FeedName -> IO ()) -> IO ()
replaceSubscription handle queue subscriptions name view failure = mask $ \restore -> do
current <- readIORef subscriptions
case Map.lookup name current of
Just (old, driver, _) | old.viewId == view.viewId -> atomically $ do
Queue.purgeFeed queue name
Driver.enqueueSnapshot driver queue
previous -> do
acquired <- Feed.acquireDriver handle view
case acquired of
Left problem ->
restore
( failure
( case problem of
Feed.TooManyViews -> P.TooManyViews
Feed.FeedBusy -> P.InspectionBusy
Feed.FeedRetiring -> P.ViewRetiring
Feed.InitialResultTooLarge -> P.InspectionResultTooLarge
_ -> P.ReadFailed
)
"subscription could not be initialized"
(Just name)
)
Right driver -> do
subscriber <-
( atomically $ do
forM_ previous (\(_, old, subscriber) -> Driver.unsubscribeSTM old subscriber)
Driver.subscribeOnQueue driver queue
)
`onException` Feed.releaseDriver handle view
writeIORef subscriptions (Map.insert name (view, driver, subscriber) current)
forM_ previous (\(old, _, _) -> Feed.releaseDriver handle old)