packages feed

kitchen-sink-0.1.0.0: src/KitchenSink/Engine/MultiSite.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module KitchenSink.Engine.MultiSite where

import Control.Concurrent.Async (Concurrently (..))
import Control.Monad (filterM)
import Data.ByteString qualified as ByteString
import Data.Function ((&))
import Data.List (concatMap)
import Data.List qualified as List
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.List.NonEmpty qualified as NEList
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Time.Clock (getCurrentTime)
import Network.HTTP.Client qualified as Http
import Network.HTTP.Client.TLS (newTlsManagerWith)
import Network.HTTP.ReverseProxy qualified as WaiProxy
import Network.HTTP.Types.Status (status404)
import Network.TLS qualified as TLS
import Network.Wai qualified as Wai
import Network.Wai.Handler.Warp as Warp
import Network.Wai.Handler.WarpTLS qualified as WarpTLS
import Network.Wai.Middleware.RequestLogger qualified as RequestLogger
import Options.Generic
import Paths_prodapi_core qualified
import Prod.App qualified as Prod
import Prod.Proxy qualified as ProdProxy
import Prod.Proxy.MultiApp qualified as ProdProxy
import Prod.Status
import Prod.Tracer
import Prometheus qualified as Prometheus
import Servant
import System.Directory (doesFileExist)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
import Prelude (id, (&&))

import KitchenSink.Core.Build.Target (Target)
import KitchenSink.Engine.Config (ApiProxyConfig (..), Prefix, RewriteRule (..), SlashApiProxyDirective (..), TransportSecurity (..))
import KitchenSink.Engine.Counters (timeItWithLabel)
import KitchenSink.Engine.Diagnostics (reportDiagnostics)
import KitchenSink.Engine.MultiSiteConfig
import KitchenSink.Engine.OnTheFly (OnTheFlyCounters (..), findTarget, handleOnTheFlyProduction)
import KitchenSink.Engine.Runtime (Engine (..))
import KitchenSink.Engine.SiteBuilder (produceTarget)
import KitchenSink.Engine.SiteConfig
import KitchenSink.Engine.SiteLoader qualified as SiteLoader
import KitchenSink.Engine.Track (DevServerTrack (..))
import KitchenSink.Engine.Utils
import KitchenSink.Layout.Blog as Blog
import KitchenSink.Prelude

data Args
    = Args
    { configFile :: FilePath
    , variables :: [(Text, Text)]
    , httpPort :: Maybe Int
    , httpsPort :: Maybe Int
    , tlsKeyFile :: Maybe FilePath
    , tlsCertFile :: Maybe FilePath
    , proxyingTimeout :: Maybe Int
    }
    deriving (Generic, Show)

type MultiSiteApi = Raw

data Counters
    = Counters
    { count_Requests :: Prometheus.Vector (Text, Text, Text) Prometheus.Counter
    , duration_Build :: Prometheus.Vector (Text, Text) Prometheus.Summary
    , count_ProxiedRequests :: Prometheus.Vector (Text, Text) Prometheus.Counter
    }

initCounters :: IO Counters
initCounters =
    Counters
        <$> reg1c "ks_targets_requests" ("app", "status", "path") "number of queries per blog target"
        <*> reg1s "ks_ontheflybuild_time" ("app", "path") "time spent building targets on disk"
        <*> reg1c "ks_proxied_requests" ("app", "route") "number of requests proxied, per site and api route"
  where
    reg1c k t h =
        Prometheus.register
            $ Prometheus.vector t
            $ Prometheus.counter (Prometheus.Info k h)
    reg1s k t h =
        Prometheus.register
            $ Prometheus.vector t
            $ Prometheus.summary (Prometheus.Info k h) Prometheus.defaultQuantiles

ontheflyCounters :: HostName -> Counters -> OnTheFlyCounters a
ontheflyCounters a cntrs =
    OnTheFlyCounters
        (\(s, p) -> Prometheus.withLabel cntrs.count_Requests (a, s, p) Prometheus.incCounter)
        (\(p) work -> timeItWithLabel cntrs.duration_Build (a, p) work)

data Runtime
    = Runtime
    { counters :: Counters
    , httpManager :: Http.Manager
    , proxyCounters :: ProdProxy.Counters
    , vars :: [(Text, Text)]
    }

initRuntime :: Args -> IO Runtime
initRuntime args =
    Runtime
        <$> initCounters
        <*> newTlsManagerWith managersettings
        <*> ProdProxy.initCounters
        <*> pure args.variables
  where
    managersettings :: Http.ManagerSettings
    managersettings =
        Http.defaultManagerSettings
            & maybe id (\t setts -> setts{Http.managerResponseTimeout = Http.responseTimeoutMicro t}) timeout

    timeout :: Maybe Int
    timeout = args.proxyingTimeout

run :: Args -> IO ()
run cmd = do
    mcfg <- loadConfigFile @MultiSiteConfig cmd.configFile
    rt <- initRuntime cmd
    case mcfg of
        Left err -> hPutStrLn stderr ("could not load config: " <> err) >> exitFailure
        Right cfg -> do
            mfallback <- buildFallbackApp rt cfg
            mapps <- buildApplicationMap rt cfg
            mtls <- buildTLSMap cfg
            case (mfallback, mapps, mtls) of
                (_, _, Left err) -> print ("could not load config certs" :: Text, err)
                (Nothing, _, _) -> print ("could not setup fallback app" :: Text)
                (Just (_, fallbackApp), apps, Right creds) -> do
                    print $ Map.keys apps
                    tlsSettings <- loadTLSSettings creds
                    let multiapp = ProdProxy.routeApplication apps fallbackApp
                    healthRt <- Prod.alwaysReadyRuntime tracePrint
                    let status = pure ("ok" :: Text)
                    init <- Prod.initialize healthRt
                    let prodapiapp =
                            Prod.app
                                init
                                status
                                (statusPage <> versionsSection [("prodapi", Paths_prodapi_core.version)] <> metricsSection "js/metrics.js")
                                (coerce multiapp)
                                (Proxy @MultiSiteApi)
                    let webapp = RequestLogger.logStdoutDev prodapiapp
                    let httpWarp = Warp.run <$> cmd.httpPort <*> pure webapp
                    let httpsWarp = WarpTLS.runTLS <$> tlsSettings <*> tlsWarpSettings <*> pure webapp
                    let program = (,) <$> Concurrently (mio httpWarp) <*> Concurrently (mio httpsWarp)
                    void $ runConcurrently program
  where
    -- NOTE that there appear to be some repetition in the fallback of TLS certificates.
    -- We actually must provide two fallback:
    -- \* (a) one that warpTLS expects
    -- \* (b) one that ProdProxy.withTLSCredentialMap expects
    -- The credential (b) serves as default when SNI fails to find a hostname.
    -- The credential (a) serves as default when there is no SNI indication.
    -- Today these two situations are conflated and if you end up needing to
    -- separate the two for any reason, let me know.
    loadTLSSettings :: ProdProxy.CredentialMap -> IO (Maybe WarpTLS.TLSSettings)
    loadTLSSettings creds = do
        fallback <- loadDefaultCredentials
        pure $ bundleTLSSettings creds =<< fallback

    bundleTLSSettings :: ProdProxy.CredentialMap -> TLS.Credentials -> Maybe WarpTLS.TLSSettings
    bundleTLSSettings creds fallback = do
        cert <- cmd.tlsCertFile
        key <- cmd.tlsKeyFile
        let base = WarpTLS.tlsSettings cert key
        pure $ ProdProxy.withTLSCredentialMap creds fallback base

    tlsWarpSettings :: Maybe Warp.Settings
    tlsWarpSettings = do
        port <- cmd.httpsPort
        pure $ Warp.setPort port Warp.defaultSettings

    loadDefaultCredentials :: IO (Maybe TLS.Credentials)
    loadDefaultCredentials = do
        let cert = cmd.tlsCertFile
        let key = cmd.tlsKeyFile
        let go = TLS.credentialLoadX509 <$> cert <*> key
        case go of
            Nothing -> pure Nothing
            Just fetch -> do
                x <- fetch
                case x of
                    Left _ -> pure $ Nothing
                    Right c -> pure $ Just $ TLS.Credentials [c]

loadMetadata :: SiteInfo -> IO MetaData
loadMetadata config = do
    MetaData
        <$> getCurrentTime
        <*> pure (title config)
        <*> pure (publishURL config)
        <*> pure (twitterLogin config)
        <*> pure noExtraHeaders
        <*> pure (maybe [] (fmap baseURL) $ linkedSites config)
        <*> pure (normalizedBasePath config)
        <*> pure (resolveHomeLink config)
        <*> pure (resolveMenu config)
        <*> pure (resolveFooter config)
  where
    noExtraHeaders _ = pure mempty

type SiteOnlyApi = Raw
type ProxyOnlyApi = Raw
type SiteAndProxyApi =
    ("api" :> ProxyOnlyApi) :<|> SiteOnlyApi

buildSiteApplication :: Runtime -> SiteStanza -> IO (Maybe (NEList.NonEmpty TLS.HostName, Wai.Application))
buildSiteApplication rt cfg = do
    proxyApp <- fmap (countProxied rt.counters cfg.domain cfg.api . ProdProxy.handleProxy) <$> buildProxyBackend rt cfg.api
    ksApp <- case cfg.site of
        NoFiles -> pure Nothing
        KitchenSinkDirectorySource src ->
            Just <$> buildDirectorySourceApp rt src cfg
    pure $ wrap <$> fullApp proxyApp ksApp
  where
    fullApp :: Maybe Wai.Application -> Maybe Wai.Application -> Maybe Wai.Application
    fullApp Nothing Nothing =
        Nothing
    fullApp Nothing (Just y) =
        Just $ Servant.serve (Proxy @SiteOnlyApi) (coerce y)
    fullApp (Just x) Nothing =
        Just $ Servant.serve (Proxy @ProxyOnlyApi) (coerce x)
    fullApp (Just x) (Just y) =
        Just $ Servant.serve (Proxy @SiteAndProxyApi) ((coerce x) :<|> (coerce y))

    wrap :: Wai.Application -> (NEList.NonEmpty TLS.HostName, Wai.Application)
    wrap v = do
        let k = Text.unpack cfg.domain
        let ks = fmap Text.unpack cfg.extraDomains
        (k :| ks, v)

{- | The route a proxied request falls under, used as a metric label: the
prefix of the first matching directive, "no-route" when none matches, and "*"
for a site proxying everything to one backend.
-}
proxyRouteLabel :: ApiProxyConfig -> Wai.Request -> Text
proxyRouteLabel cfg req =
    case cfg of
        NoProxying -> "none"
        SlashApiProxy _ _ -> "*"
        SlashApiProxyList directives ->
            maybe "no-route" (\d -> d.prefix) (List.find (matchesDirective req) directives)

matchesDirective :: Wai.Request -> SlashApiProxyDirective -> Bool
matchesDirective req directive = Text.encodeUtf8 directive.prefix `ByteString.isPrefixOf` Wai.rawPathInfo req

{- | Counts each proxied request under its site and route. The proxy library
only has a single unlabeled counter (still exposed as `cnt_proxied_requests`,
the total), so the keyed one lives here, around the proxy application.
-}
countProxied :: Counters -> Text -> ApiProxyConfig -> Wai.Application -> Wai.Application
countProxied cntrs domain cfg app req rsp = do
    Prometheus.withLabel cntrs.count_ProxiedRequests (domain, proxyRouteLabel cfg req) Prometheus.incCounter
    app req rsp

mkProdProxyRuntime :: Runtime -> ProdProxy.Backends -> ProdProxy.Runtime
mkProdProxyRuntime rt backends =
    ProdProxy.Runtime
        (rt.proxyCounters)
        (backends)
        (rt.httpManager)

buildProxyBackend :: Runtime -> ApiProxyConfig -> IO (Maybe ProdProxy.Runtime)
buildProxyBackend rt cfg =
    case cfg of
        NoProxying -> pure Nothing
        (SlashApiProxyList []) -> pure Nothing
        (SlashApiProxy host port) ->
            pure $ Just $ mkProdProxyRuntime rt (static (Text.encodeUtf8 host) port)
        (SlashApiProxyList directives) -> do
            pure $ Just $ mkProdProxyRuntime rt (prefixed directives)
  where
    proxydest host port = WaiProxy.ProxyDest host port
    dest host port = WaiProxy.WPRProxyDest $ proxydest host port
    destSecure host port = WaiProxy.WPRProxyDestSecure $ proxydest host port
    mkdest directive =
        let
            host = Text.encodeUtf8 directive.hostname
         in
            case (directive.rewrite, directive.security) of
                (NoRewrite, UsePlainText) -> const $ dest host directive.portnum
                (NoRewrite, UseHTTPS) -> const $ destSecure host directive.portnum
                (DropPrefix, UsePlainText) ->
                    \req -> WaiProxy.WPRModifiedRequest (stripPrefix directive.prefix req) (proxydest host directive.portnum)
                (DropPrefix, UseHTTPS) ->
                    \req -> WaiProxy.WPRModifiedRequestSecure (stripPrefix directive.prefix req) (proxydest host directive.portnum)
                (RewritePrefix pfx, UsePlainText) ->
                    \req -> WaiProxy.WPRModifiedRequest (rewritePrefix directive.prefix pfx req) (proxydest host directive.portnum)
                (RewritePrefix pfx, UseHTTPS) ->
                    \req -> WaiProxy.WPRModifiedRequestSecure (rewritePrefix directive.prefix pfx req) (proxydest host directive.portnum)
                (RewritePrefixHost pfx newHost, UsePlainText) ->
                    \req -> WaiProxy.WPRModifiedRequest (changeHost (Text.encodeUtf8 newHost) $ rewritePrefix directive.prefix pfx $ req) (proxydest host directive.portnum)
                (RewritePrefixHost pfx newHost, UseHTTPS) ->
                    \req -> WaiProxy.WPRModifiedRequestSecure (changeHost (Text.encodeUtf8 newHost) $ rewritePrefix directive.prefix pfx $ req) (proxydest host directive.portnum)

    stripPrefix :: Prefix -> Wai.Request -> Wai.Request
    stripPrefix pfx req = req{Wai.rawPathInfo = ByteString.drop (ByteString.length $ Text.encodeUtf8 pfx) req.rawPathInfo}

    rewritePrefix :: Prefix -> Prefix -> Wai.Request -> Wai.Request
    rewritePrefix pfx newPfx req = req{Wai.rawPathInfo = (Text.encodeUtf8 newPfx) <> ByteString.drop (ByteString.length $ Text.encodeUtf8 pfx) req.rawPathInfo}

    changeHost :: ByteString.ByteString -> Wai.Request -> Wai.Request
    changeHost newHost req =
        let
            notHostHeader (hn, _) = hn /= "host"
         in
            req
                { Wai.requestHeaderHost = Just newHost
                , Wai.requestHeaders = ("host", newHost) : List.filter notHostHeader (Wai.requestHeaders req)
                }

    static host port =
        ProdProxy.WaiProxyBackend (\_ -> pure $ dest host port)
    prefixed directives =
        let
            destinations = [(directive, mkdest directive) | directive <- directives]
            noDest = WaiProxy.WPRResponse $ Wai.responseLBS status404 [] "no such api route"
            findDestination req = fmap (\mk -> mk req) <$> List.find (matchesDirective req . fst) destinations
            getDestination req = maybe noDest snd (findDestination req)
         in
            ProdProxy.WaiProxyBackend (\req -> pure $ getDestination req)

buildDirectorySourceApp :: Runtime -> KitchenSinkDirectorySourceStanza -> SiteStanza -> IO Wai.Application
buildDirectorySourceApp rt src cfg = do
    -- loaded once for the whole duration of the application
    metadata <- loadMetadata src.metadata
    site <- loadSource metadata
    let targets = evalTargets metadata site
    let engine = Engine (pure site) (pure metadata) (\_ _ -> targets) (produceTarget print)
    let webapp =
            handleOnTheFlyProduction
                (findTarget engine (pure site) tracePrint)
                (ontheflyCounters cfg.domain rt.counters)
                tracePrint
    pure webapp
  where
    -- An output prefix that is unused when generating results on the fly.
    unusedPrefix :: FilePath
    unusedPrefix = ""

    evalTargets :: MetaData -> SiteLoader.Site () -> [Target () ()]
    evalTargets med site =
        fmap (fmap $ const ())
            $ (siteTargets Blog.layout) (src.execRoot) unusedPrefix med site

    loadSource :: MetaData -> IO (SiteLoader.Site ())
    loadSource med = do
        site <-
            SiteLoader.loadSite
                rt.vars
                med.pathPrefix
                (extraSectiontypes Blog.layout)
                (runTracer $ contramap Loading $ tracePrint)
                src.path
        reportDiagnostics (siteDiagnostics Blog.layout site)
        pure site

buildFallbackApp :: Runtime -> MultiSiteConfig -> IO (Maybe (TLS.HostName, Wai.Application))
buildFallbackApp rt cfg = case cfg.fallback of
    FallbackWithOminousError -> pure $ Just ("*", ominousApp)
    FallbackSite s ->
        let pickHeadHost (hosts, a) = (NEList.head hosts, a)
         in fmap pickHeadHost <$> buildSiteApplication rt s
  where
    ominousApp :: Wai.Application
    ominousApp = \_ reply -> reply $ Wai.responseLBS status404 [] "ominous error"

buildApplicationMap :: Runtime -> MultiSiteConfig -> IO ProdProxy.ApplicationMap
buildApplicationMap rt cfg =
    let
        flattenHosts (hosts, a) = [(host, a) | host <- NEList.toList hosts]
        bundleApps = Map.fromList . concatMap flattenHosts
     in
        bundleApps . catMaybes <$> traverse (buildSiteApplication rt) cfg.services

buildTLSMap :: MultiSiteConfig -> IO (Either String ProdProxy.CredentialMap)
buildTLSMap cfg = do
    triplets <- filterM certificateFilesExist
        $ mconcat
        $ fmap siteTLSTriplet cfg.services
    ProdProxy.loadCredentialMap triplets
  where
    -- A missing cert/key file must not crash the whole daemon: we warn and
    -- drop the entry, letting that host fall through to the process-wide
    -- fallback certificate at TLS handshake time (see the NOTE in `run`).
    certificateFilesExist :: (TLS.HostName, ProdProxy.X509Path, ProdProxy.PrivateKeyPath) -> IO Bool
    certificateFilesExist (host, pemPath, keyPath) = do
        pemExists <- doesFileExist pemPath
        keyExists <- doesFileExist keyPath
        if pemExists && keyExists
            then pure True
            else do
                print
                    ( "warning: missing TLS certificate files for host, falling back to the default certificate" :: Text
                    , host
                    , pemPath
                    , keyPath
                    )
                pure False

siteTLSTriplet :: SiteStanza -> [(TLS.HostName, ProdProxy.X509Path, ProdProxy.PrivateKeyPath)]
siteTLSTriplet cfg = do
    t <- cfg.tls
    c <- toList (tlsCert t)
    d <- join (toList (t.sniDomains <|> (Just [cfg.domain])))
    pure (Text.unpack d, c.pem, c.key)
  where
    tlsCert :: TLSStanza -> Maybe CertificateFiles
    tlsCert s = case s.certificate of
        NoCertificates -> Nothing
        CertificateFileSource x -> Just x