eventlog-live-otelcol-0.6.1.0: src/GHC/Eventlog/Live/Otelcol/Control.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
module GHC.Eventlog.Live.Otelcol.Control (
ControlOptions (..),
ControlPort (..),
ControlCors (..),
ControlCorsAllowOrigin (..),
ControlServerApi (..),
controlOptionsParser,
startControlServer,
) where
import Control.Applicative (asum)
import Data.ByteString.Char8 qualified as BSC
import Data.Char (isSpace)
import Data.Maybe (isJust)
import GHC.Eventlog.Live.Logger (Logger)
import GHC.Eventlog.Live.Otelcol.Config (ServiceName)
import GHC.Eventlog.Live.Source.Core (EventlogSourceHandle (..))
import Options.Applicative qualified as O
import Options.Applicative.Compat qualified as OC
import Options.Applicative.Extra.Feature (Feature (..))
import Options.Applicative.Extra.Feature qualified as OF
import Text.ParserCombinators.ReadP (ReadP)
import Text.ParserCombinators.ReadP qualified as P
#ifdef EVENTLOG_LIVE_OTELCOL_FEATURE_CONTROL
import Control.Concurrent (forkIO, killThread)
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TVar (TVar, modifyTVar', newTVarIO, readTVarIO)
import Control.Exception (Exception (..), catches)
import Control.Exception qualified as E (Handler (..))
import Control.Monad.IO.Class (MonadIO (..))
import Data.Aeson qualified as JSON
import Data.Aeson.Types (FromJSON (..), ToJSON (..), Parser, Value, genericParseJSON, genericToJSON)
import Data.Binary qualified as B
import Data.ByteString.Lazy qualified as BSL
import Data.Char (isLower, isUpper, toLower)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as M
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Word (Word8)
import GHC.Eventlog.Live.Data.Severity (Severity (..))
import GHC.Eventlog.Live.Logger (writeLog)
import GHC.Eventlog.Live.Otelcol.Config (ServiceName (..))
import GHC.Eventlog.Socket.Control qualified as C
import GHC.Generics (Generic)
import Network.Socket (Socket)
import Network.Socket.ByteString.Lazy qualified as SBSL
import Network.Wai (Middleware)
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..), Origin, cors)
import Network.Wai.Middleware.RequestLogger (Destination (..), DetailedSettings (..), OutputFormat (..), RequestLoggerSettings (..), defaultDetailedSettings, defaultRequestLoggerSettings, mkRequestLogger)
import Servant (PlainText, throwError)
import Servant.API (Capture, FormUrlEncoded, Get, Header, Headers, JSON, NoContent (..), PostAccepted, ReqBody, StdMethod (..), UVerb, Union, WithStatus (..), addHeader, (:>), type (:<|>) (..))
import Servant.Server (Handler, Server, ServerError (..), serve, respond)
import System.Log.FastLogger (fromLogStr)
import Web.FormUrlEncoded (Form, FormOptions, FromForm (..), genericFromForm)
import Web.FormUrlEncoded qualified as Form (FormOptions (fieldLabelModifier), defaultFormOptions)
#else
import Control.Monad (when)
#endif
--------------------------------------------------------------------------------
-- Feature: control
--------------------------------------------------------------------------------
control :: Feature
control = Feature{flag = "control", isOn = isOn, info = "Cannot start control server."}
where
isOn :: Bool
#ifdef EVENTLOG_LIVE_OTELCOL_FEATURE_CONTROL
isOn = True
#else
isOn = False
#endif
--------------------------------------------------------------------------------
-- Control App
--------------------------------------------------------------------------------
data ControlServerApi = ControlServerApi
{ notifyNewConnection :: ServiceName -> EventlogSourceHandle -> IO ()
, notifyEndConnection :: ServiceName -> IO ()
, stop :: IO ()
}
noopControlServerApi :: ControlServerApi
noopControlServerApi =
ControlServerApi
{ notifyNewConnection = \_serviceName _eventlogSourceHandle -> pure ()
, notifyEndConnection = \_serviceName -> pure ()
, stop = pure ()
}
{- |
If the binary is built with -f+control and the control server is enabled from
the CLI, this starts the control server and returns the control server API.
If the binary is NOT built with -f+control, but the control server is enabled
from the CLI, this prints an error and exits the process.
If the control server is NOT enabled from the CLI, this returns a no-op API.
-}
startControlServer :: Logger IO -> ControlOptions -> IO ControlServerApi
--------------------------------------------------------------------------------
-- Control App - Disabled
--------------------------------------------------------------------------------
#ifndef EVENTLOG_LIVE_OTELCOL_FEATURE_CONTROL
startControlServer logger controlOptions = do
when (shouldStart controlOptions) $
OF.exitIfUnsupported control logger
pure noopControlServerApi
--------------------------------------------------------------------------------
-- Control App - Enabled
--------------------------------------------------------------------------------
#else
startControlServer logger controlOptions
| shouldStart controlOptions = do
-- Determine the control port
let port = maybe 30179 (.port) controlOptions.controlPort
writeLog logger INFO $
"Starting control server on " <> T.pack (show port)
-- Create middleware that logs all incoming requests.
requestLogger <- mkLoggerMiddleware logger
-- Create variable for storing sockets.
eventlogSourceHandleMap <- newTVarIO M.empty
-- Create CORS resource policy.
--
-- NOTE: The @wai-cors@ package lets the resource policy depend dynamically
-- on the received request. However, it is difficult to expose this freedom
-- via command-line arguments.
--
-- TODO: We might have to manually find the origin in the request, check it
-- against the allow-list, and set the @corsOrigins@ field.
let !corsResourcePolicy = mkCorsResourcePolicy controlOptions.controlCors
-- Start control server.
controlServerThreadId <-
forkIO $
Warp.run port $
requestLogger $
cors (const $ Just corsResourcePolicy) $
serve (Proxy @(HealthApi :<|> ControlApi)) $
controlServer logger eventlogSourceHandleMap (corsIgnoreFailures corsResourcePolicy)
-- When notified of a new connection, update the eventlogSourceHandleMap.
let notifyNewConnection serviceName eventlogSourceHandle = do
writeLog logger DEBUG $
"New connection for service " <> serviceName.serviceName <> "."
atomically $
modifyTVar' eventlogSourceHandleMap (M.insert serviceName eventlogSourceHandle)
-- When notified of the end of a connection, update the eventlogSourceHandleMap.
let notifyEndConnection serviceName = do
writeLog logger DEBUG $
"End connection for service " <> serviceName.serviceName <> "."
atomically $
modifyTVar' eventlogSourceHandleMap (M.delete serviceName)
-- When requested to stop, kill the control server thread.
let stop = killThread controlServerThreadId
pure ControlServerApi{..}
| otherwise =
pure noopControlServerApi
mkLoggerMiddleware :: Logger IO -> IO Middleware
mkLoggerMiddleware logger =
mkRequestLogger $
defaultRequestLoggerSettings
{ destination = Callback $ writeLog logger TRACE2 . TE.decodeUtf8Lenient . fromLogStr
, outputFormat =
DetailedWithSettings
defaultDetailedSettings
{ useColors = False
}
}
mkCorsResourcePolicy :: ControlCors -> CorsResourcePolicy
mkCorsResourcePolicy ControlCors{..} =
CorsResourcePolicy
{ corsOrigins = corsOrigins
, corsMethods = ["GET", "POST"]
, corsRequestHeaders = ["Content-Type", "Origin"]
, corsExposedHeaders = Nothing
, corsMaxAge = controlCorsMaxAgeS
, corsVaryOrigin = corsVaryOrigin
, corsRequireOrigin = controlCorsRequireOrigin
, corsIgnoreFailures = controlCorsIgnoreFailures
}
where
-- TODO: If we add authentication, this flag needs to be set.
corsCredentials :: Bool
corsCredentials = False
-- The @wai-cors@ package represents wildcard as @Nothing@ and otherwise
-- accepts a list of origins and a boolean that determines whether or not
-- credentials are used to access the resource.
corsOrigins :: Maybe ([Origin], Bool)
corsOrigins =
case controlCorsAllowOrigin of
Just (ControlCorsAllowOriginList origins) -> Just (origins, corsCredentials)
_otherwise -> Nothing
-- The @Vary: Origin@ header must be added if @Access-Control-Allow-Origin@
-- is not set to wildcard and there are multiple origins.
corsVaryOrigin :: Bool
corsVaryOrigin =
case controlCorsAllowOrigin of
Just (ControlCorsAllowOriginList origins) -> length origins >= 2
_otherwise -> False
--------------------------------------------------------------------------------
-- Control Server
controlServer :: Logger IO -> TVar (HashMap ServiceName EventlogSourceHandle) -> Bool -> Server (HealthApi :<|> ControlApi)
controlServer logger eventlogSourceHandleMapVar corsIgnoreFailures =
health :<|> controlApi
where
health :: Handler ()
health = do
liftIO . writeLog logger DEBUG $
"Received request on /health."
controlApi :: Server ControlApi
controlApi = eventlogSocket :<|> customCommand
where
customCommand :: Server CustomCommandApi
customCommand namespaceText commandId = callCustomCommand :<|> badCORSPreflight
where
callCustomCommand :: CustomCommandReq -> Handler (Union '[CustomCommandAccept, CustomCommandReject])
callCustomCommand req = do
liftIO . writeLog logger DEBUG $
"Received request on /control/" <> namespaceText <> " with command ID " <> T.pack (show commandId) <> " for " <> req.serviceName <> "."
-- Construct the user namespace.
liftIO (eitherUserNamespace namespaceText) >>= \case
-- If userNamespace throws an exception, then...
Left customCommandError ->
-- ...respond with a custom command error.
respond . WithStatus @404 $
customCommandError
-- Otherwise, ...
Right namespace -> do
-- ...construct the user command...
let command = C.userCommand namespace (C.CommandId commandId)
-- ...send the user command...
withSocketFor (ServiceName req.serviceName) $ \socket -> do
liftIO (SBSL.sendAll socket $ B.encode command)
-- ...respond with a success status.
respond . WithStatus @204 $
NoContent
eventlogSocket :: Server EventlogSocketApi
eventlogSocket =
(startProfiling :<|> badCORSPreflight)
:<|> (stopProfiling :<|> badCORSPreflight)
:<|> (startHeapProfiling :<|> badCORSPreflight)
:<|> (stopHeapProfiling :<|> badCORSPreflight)
:<|> (requestHeapCensus :<|> badCORSPreflight)
where
startProfiling :: StartProfilingReq -> Handler ()
startProfiling req = do
liftIO . writeLog logger DEBUG $
"Received request on /control/eventlog-socket/start-profiling for " <> req.serviceName <> "."
-- Send the control command over the socket.
withSocketFor (ServiceName req.serviceName) $ \socket -> do
liftIO (SBSL.sendAll socket $ B.encode C.startProfiling)
stopProfiling :: StopProfilingReq -> Handler ()
stopProfiling req = do
liftIO . writeLog logger DEBUG $
"Received request on /control/eventlog-socket/stop-profiling for " <> req.serviceName <> "."
-- Send the control command over the socket.
withSocketFor (ServiceName req.serviceName) $ \socket -> do
liftIO (SBSL.sendAll socket $ B.encode C.stopProfiling)
startHeapProfiling :: StartHeapProfilingReq -> Handler ()
startHeapProfiling req = do
liftIO . writeLog logger DEBUG $
"Received request on /control/eventlog-socket/start-heap-profiling for " <> req.serviceName <> "."
-- Send the control command over the socket.
withSocketFor (ServiceName req.serviceName) $ \socket -> do
liftIO (SBSL.sendAll socket $ B.encode C.startHeapProfiling)
stopHeapProfiling :: StopHeapProfilingReq -> Handler ()
stopHeapProfiling req = do
liftIO . writeLog logger DEBUG $
"Received request on /control/eventlog-socket/stop-heap-profiling for " <> req.serviceName <> "."
-- Send the control command over the socket.
withSocketFor (ServiceName req.serviceName) $ \socket -> do
liftIO (SBSL.sendAll socket $ B.encode C.stopHeapProfiling)
requestHeapCensus :: RequestHeapCensusReq -> Handler ()
requestHeapCensus req = do
liftIO . writeLog logger DEBUG $
"Received request on /control/eventlog-socket/request-heap-census for " <> req.serviceName <> "."
-- Send the control command over the socket.
withSocketFor (ServiceName req.serviceName) $ \socket -> do
liftIO (SBSL.sendAll socket $ B.encode C.requestHeapCensus)
badCORSPreflight :: Handler (Union '[BadCORSPreflightAccept, BadCORSPreflightReject])
badCORSPreflight
| corsIgnoreFailures = do
-- This accepts malformed CORS preflight requests.
liftIO . writeLog logger DEBUG $ "Accepted malformed CORS preflight request."
respond . WithStatus @204 $
addHeader @"Access-Control-Allow-Origin" @String "*" $
addHeader @"Access-Control-Allow-Methods" @String "GET, POST" $
addHeader @"Access-Control-Allow-Headers" @String "*" $
NoContent
| otherwise = do
liftIO . writeLog logger DEBUG $ "Accepted malformed CORS preflight request."
respond . WithStatus @400 $
NoContent
withSocketFor :: ServiceName -> (Socket -> Handler ()) -> Handler ()
withSocketFor serviceName action = do
eventlogSourceHandleMap <- liftIO (readTVarIO eventlogSourceHandleMapVar)
case M.lookup serviceName eventlogSourceHandleMap of
-- If the service is not known, return a 404.
Nothing ->
throwError
ServerError
{ errHTTPCode = 404
, errReasonPhrase = "Not Found"
, errBody = BSL.fromStrict . TE.encodeUtf8 $ "Could not find eventlog socket for service " <> serviceName.serviceName <> "."
, errHeaders = []
}
-- If the service telemetry is streamed from stdin or a file, return a 400.
Just EventlogSourceHandleStdin ->
throwError
ServerError
{ errHTTPCode = 400
, errReasonPhrase = "Bad Request"
, errBody = BSL.fromStrict . TE.encodeUtf8 $ "The telemetry for service " <> serviceName.serviceName <> " is streamed from stdin."
, errHeaders = []
}
Just (EventlogSourceHandleFile _h) ->
throwError
ServerError
{ errHTTPCode = 400
, errReasonPhrase = "Bad Request"
, errBody = BSL.fromStrict . TE.encodeUtf8 $ "The telemetry for service " <> serviceName.serviceName <> " is streamed from a file."
, errHeaders = []
}
-- If the service telemetry is streamed from a socket, continue.
Just (EventlogSourceHandleSocket s) -> action s
--------------------------------------------------------------------------------
-- Control API
type ControlApi =
"control" :> (EventlogSocketApi :<|> CustomCommandApi)
--------------------------------------------------------------------------------
-- Health API
type HealthApi =
"health" :> Get '[JSON] ()
--------------------------------------------------------------------------------
-- Custom Command API
type CustomCommandApi =
Capture "namespace" Text
:> Capture "commandId" Word8
:> ((ReqBody '[FormUrlEncoded, JSON] CustomCommandReq
:> UVerb 'POST '[JSON] '[CustomCommandAccept, CustomCommandReject]) :<|> BadCORSPreflight)
type CustomCommandAccept = WithStatus 204 NoContent
type CustomCommandReject = WithStatus 404 CustomCommandError
newtype CustomCommandError = CustomCommandError
{ errorMessage :: String
}
deriving (Generic, Show)
eitherUserNamespace :: Text -> IO (Either CustomCommandError C.Namespace)
eitherUserNamespace namespace =
(pure . Right . C.userNamespace $ namespace) `catches` handlers
where
handlers =
[ E.Handler $ pure . Left . toCustomCommandError @C.NamespaceReservedError
, E.Handler $ pure . Left . toCustomCommandError @C.NamespaceTooLongError
]
toCustomCommandError :: (Exception e) => e -> CustomCommandError
toCustomCommandError = CustomCommandError . displayException
instance ToJSON CustomCommandError where
toJSON :: CustomCommandError -> Value
toJSON = genericToJSON myJSONOptions
--------------------------------------------------------------------------------
-- CustomCommandReq
data CustomCommandReq = CustomCommandReq
{ serviceName :: Text
}
deriving (Generic, Show)
instance FromForm CustomCommandReq where
fromForm :: Form -> Either Text CustomCommandReq
fromForm = genericFromForm myFormOptions
instance FromJSON CustomCommandReq where
parseJSON :: Value -> Parser CustomCommandReq
parseJSON = genericParseJSON myJSONOptions
--------------------------------------------------------------------------------
-- Eventlog Socket API
-- 2025-12-11:
-- Ideally, the /heap-profiling API would attach the semantics of /start and
-- /stop to a PUT and DELETE request on /heap-profiling with 204 No Content
-- responses. This would give us exactly the right caching semantics.
-- However, I have not been able to find any Grafana plugins that support
-- sending PUT and DELETE requests, and servant appears to have trouble adding
-- headers to 204 No Content responses, so I'm using POST requests on separate
-- /start and /stop endpoints.
type EventlogSocketApi =
"eventlog-socket"
:> ("start-profiling" :> (StartProfilingApi :<|> BadCORSPreflight)
:<|> "stop-profiling" :> (StopProfilingApi :<|> BadCORSPreflight)
:<|> "start-heap-profiling" :> (StartHeapProfilingApi :<|> BadCORSPreflight)
:<|> "stop-heap-profiling" :> (StopHeapProfilingApi :<|> BadCORSPreflight)
:<|> "request-heap-census" :> (RequestHeapCensusApi :<|> BadCORSPreflight)
)
type StartProfilingApi =
ReqBody '[FormUrlEncoded, JSON] StartProfilingReq
:> PostAccepted '[JSON] ()
type StopProfilingApi =
ReqBody '[FormUrlEncoded, JSON] StopProfilingReq
:> PostAccepted '[JSON] ()
type StartHeapProfilingApi =
ReqBody '[FormUrlEncoded, JSON] StartHeapProfilingReq
:> PostAccepted '[JSON] ()
type StopHeapProfilingApi =
ReqBody '[FormUrlEncoded, JSON] StopHeapProfilingReq
:> PostAccepted '[JSON] ()
type RequestHeapCensusApi =
ReqBody '[FormUrlEncoded, JSON] RequestHeapCensusReq
:> PostAccepted '[JSON] ()
type BadCORSPreflight =
UVerb 'OPTIONS '[PlainText] '[BadCORSPreflightAccept, BadCORSPreflightReject]
type BadCORSPreflightAccept = WithStatus 204 (Headers
'[ Header "Access-Control-Allow-Origin" String
, Header "Access-Control-Allow-Methods" String
, Header "Access-Control-Allow-Headers" String
]
NoContent)
type BadCORSPreflightReject = WithStatus 400 NoContent
--------------------------------------------------------------------------------
-- StartProfilingReq
newtype StartProfilingReq = StartProfilingReq
{ serviceName :: Text
}
deriving (Generic, Show)
instance FromForm StartProfilingReq where
fromForm :: Form -> Either Text StartProfilingReq
fromForm = genericFromForm myFormOptions
instance FromJSON StartProfilingReq where
parseJSON :: Value -> Parser StartProfilingReq
parseJSON = genericParseJSON myJSONOptions
--------------------------------------------------------------------------------
-- StopProfilingReq
newtype StopProfilingReq = StopProfilingReq
{ serviceName :: Text
}
deriving (Generic, Show)
instance FromForm StopProfilingReq where
fromForm :: Form -> Either Text StopProfilingReq
fromForm = genericFromForm myFormOptions
instance FromJSON StopProfilingReq where
parseJSON :: Value -> Parser StopProfilingReq
parseJSON = genericParseJSON myJSONOptions
--------------------------------------------------------------------------------
-- StartHeapProfilingReq
newtype StartHeapProfilingReq = StartHeapProfilingReq
{ serviceName :: Text
}
deriving (Generic, Show)
instance FromForm StartHeapProfilingReq where
fromForm :: Form -> Either Text StartHeapProfilingReq
fromForm = genericFromForm myFormOptions
instance FromJSON StartHeapProfilingReq where
parseJSON :: Value -> Parser StartHeapProfilingReq
parseJSON = genericParseJSON myJSONOptions
--------------------------------------------------------------------------------
-- StopHeapProfilingReq
newtype StopHeapProfilingReq = StopHeapProfilingReq
{ serviceName :: Text
}
deriving (Generic, Show)
instance FromForm StopHeapProfilingReq where
fromForm :: Form -> Either Text StopHeapProfilingReq
fromForm = genericFromForm myFormOptions
instance FromJSON StopHeapProfilingReq where
parseJSON :: Value -> Parser StopHeapProfilingReq
parseJSON = genericParseJSON myJSONOptions
--------------------------------------------------------------------------------
-- RequestHeapCensusReq
newtype RequestHeapCensusReq = RequestHeapCensusReq
{ serviceName :: Text
}
deriving (Generic, Show)
instance FromForm RequestHeapCensusReq where
fromForm :: Form -> Either Text RequestHeapCensusReq
fromForm = genericFromForm myFormOptions
instance FromJSON RequestHeapCensusReq where
parseJSON :: Value -> Parser RequestHeapCensusReq
parseJSON = genericParseJSON myJSONOptions
--------------------------------------------------------------------------------
-- Internal helpers.
-- | Generic options for `FromForm`.
myFormOptions :: FormOptions
myFormOptions =
Form.defaultFormOptions
{ Form.fieldLabelModifier = camelTo2 '-'
}
-- | Generic options for `FromJSON`.
myJSONOptions :: JSON.Options
myJSONOptions =
JSON.defaultOptions
{ JSON.fieldLabelModifier = camelTo2 '-'
}
-- | Taken from aeson.
camelTo2 :: Char -> String -> String
camelTo2 c = map toLower . go2 . go1
where
go1 "" = ""
go1 (x : u : l : xs) | isUpper u && isLower l = x : c : u : l : go1 xs
go1 (x : xs) = x : go1 xs
go2 "" = ""
go2 (l : u : xs) | isLower l && isUpper u = l : c : u : go2 xs
go2 (x : xs) = x : go2 xs
#endif
--------------------------------------------------------------------------------
-- Control Options
--------------------------------------------------------------------------------
data ControlOptions = ControlOptions
{ controlEnabled :: !Bool
, controlPort :: !(Maybe ControlPort)
, controlCors :: !ControlCors
}
{- |
Internal helper.
If the user provides any of the control options, this implies @--control@.
-}
shouldStart :: ControlOptions -> Bool
shouldStart controlOptions =
controlOptions.controlEnabled
|| isJust controlOptions.controlPort
|| isJust controlOptions.controlCors.controlCorsAllowOrigin
|| isJust controlOptions.controlCors.controlCorsMaxAgeS
|| controlOptions.controlCors.controlCorsRequireOrigin
|| controlOptions.controlCors.controlCorsIgnoreFailures
controlOptionsParser :: O.Parser ControlOptions
controlOptionsParser =
OC.parserOptionGroup "Control Server Options" $
ControlOptions
<$> controlEnabledParser
<*> controlPortParser
<*> controlCorsParser
controlEnabledParser :: O.Parser Bool
controlEnabledParser =
OF.onlyFor control (O.flag False True) mempty $
O.long "control"
<> OF.helpFor control "Start the control server."
newtype ControlPort = ControlPort {port :: Int}
deriving (Eq, Show)
controlPortParser :: O.Parser (Maybe ControlPort)
controlPortParser =
asum
[ OF.onlyFor control (O.option (Just . ControlPort <$> O.auto)) (O.metavar "PORT") $
O.long "control-port"
<> OF.helpFor control "The port number for the control server."
, pure Nothing
]
data ControlCors = ControlCors
{ controlCorsAllowOrigin :: !(Maybe ControlCorsAllowOrigin)
, controlCorsMaxAgeS :: !(Maybe Int)
, controlCorsRequireOrigin :: !Bool
, controlCorsIgnoreFailures :: !Bool
}
controlCorsParser :: O.Parser ControlCors
controlCorsParser =
ControlCors
<$> controlCorsAllowOriginParser
<*> controlCorsMaxAgeSParser
<*> controlCorsRequireOriginParser
<*> controlCorsIgnoreFailuresParser
controlCorsMaxAgeSParser :: O.Parser (Maybe Int)
controlCorsMaxAgeSParser =
asum
[ OF.onlyFor control (O.option (Just <$> O.auto)) (O.metavar "SECONDS") $
O.long "control-cors-max-age"
<> OF.helpFor control "Set the maximum age of a cached CORS preflight request for the control server CORS policy."
, pure Nothing
]
controlCorsRequireOriginParser :: O.Parser Bool
controlCorsRequireOriginParser =
OF.onlyFor control (O.flag False True) mempty $
O.long "control-cors-require-origin"
<> OF.helpFor control "If enabled, the control server will not accept requests without an Origin header."
controlCorsIgnoreFailuresParser :: O.Parser Bool
controlCorsIgnoreFailuresParser =
OF.onlyFor control (O.flag False True) mempty $
O.long "control-cors-ignore-failure"
<> OF.helpFor control "If enabled, the control server will accept malformed CORS preflight requests."
type ControlCorsOrigin = BSC.ByteString
data ControlCorsAllowOrigin
= ControlCorsAllowOriginWildcard
| ControlCorsAllowOriginList [ControlCorsOrigin]
controlCorsAllowOriginParser :: O.Parser (Maybe ControlCorsAllowOrigin)
controlCorsAllowOriginParser =
asum
[ OF.onlyFor control (O.option (Just <$> readPReader pControlCorsAllowOrigin)) (O.metavar "ORIGIN") $
O.long "control-cors-allow-origin"
<> OF.helpFor control "Set the allowed origins for the control server CORS policy."
, pure Nothing
]
readPReader :: ReadP a -> O.ReadM a
readPReader readP = readSReader (P.readP_to_S readP)
readSReader :: ReadS a -> O.ReadM a
readSReader readS = O.maybeReader $ \str ->
case readS str of
[(a, "")] -> Just a
_otherwise -> Nothing
pControlCorsAllowOrigin :: ReadP ControlCorsAllowOrigin
pControlCorsAllowOrigin =
P.skipSpaces
*> asum
[ -- Wildcard
ControlCorsAllowOriginWildcard <$ P.char '*' <* P.skipSpaces
, -- List of origins
ControlCorsAllowOriginList <$> P.sepBy1 pOrigin (P.char ',' <* P.skipSpaces)
]
where
-- TODO: The parser for origin could parse the syntax for origins:
--
-- Origin: null
-- Origin: <scheme>://<hostname>
-- Origin: <scheme>://<hostname>:<port>
--
pOrigin :: ReadP ControlCorsOrigin
pOrigin = BSC.pack <$> P.munch1 (\c -> not (c == ',' || isSpace c)) <* P.skipSpaces