hprox-0.7.1: src/Network/HProx/Runtime.hs
-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.
{-# LANGUAGE CPP #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Network.HProx.Runtime
( LoadedCredential(..)
, RunnerPlan(..)
, RuntimeConfig(..)
, TlsCredentialLoader
, TlsCredentialStore
, WarpRuntimePlan(..)
, buildProxyApplication
, buildProxySettings
, buildRuntimeConfig
, buildTlsSettings
, buildWarpRuntimePlan
, buildWarpSettings
, loadTlsCredentialFromMemory
, loadTlsCredentialStore
, loadTlsCredentials
, lookupSNICredentials
, lookupSNICredentialsFromStore
, lookupSNIHost
, lookupTlsCredentialStore
, newTlsCredentialStore
, readTlsCredentialStore
, reloadTlsCredentialStore
, runProxyServer
, runtimeExceptionToLog
, selectRunnerPlan
, shouldSuppressAccessLog
, sniPatternMatches
, validateRuntimeConfig
) where
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Control.Exception
(IOException, SomeException, displayException, fromException, try)
import Control.Monad (zipWithM)
import Crypto.Hash qualified as Hash
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.Default.Class (def)
import Data.IORef (IORef, atomicWriteIORef, newIORef, readIORef)
import Data.List (sortOn)
import Data.Maybe (fromMaybe, isJust)
import Data.Ord (Down(..))
import Data.String (fromString)
import GHC.IO.Exception (IOErrorType(..))
import Network.HTTP.Client qualified as HC
import Network.HTTP.Types qualified as HT
import Network.HTTP2.Client qualified as H2
import Network.TLS qualified as TLS
import Network.Wai (Application, Request, rawPathInfo)
import Network.Wai.Handler.Warp
(InvalidRequest(..), Settings, defaultSettings, defaultShouldDisplayException, runSettings,
setBeforeMainLoop, setHost, setLogger, setNoParsePath, setOnException, setPort, setServerName)
import Network.Wai.Handler.WarpTLS
(OnInsecure(..), TLSSettings, WarpTLSException, defaultTlsSettings, onInsecure, runTLS,
tlsAllowedVersions, tlsCredentials, tlsServerHooks, tlsSessionManager)
import System.IO.Error (ioeGetErrorType)
#ifdef QUIC_ENABLED
import Network.HProx.Quic
import Network.QUIC.Internal qualified as Q
#endif
import Network.HProx.Config
import Network.HProx.DoH
import Network.HProx.Impl
import Network.HProx.Log
import Network.HProx.Route
data RuntimeConfig = RuntimeConfig
{ runtimeConfigLogOutput :: !LogOutput
, runtimeConfigReverseRoutes :: ![ReverseRoute]
, runtimeConfigReverseRouteTuples :: ![(Maybe BS8.ByteString, BS8.ByteString, BS8.ByteString)]
}
deriving (Eq, Show)
data WarpRuntimePlan = WarpRuntimePlan
{ runtimeBindHost :: !String
, runtimePort :: !Int
, runtimeServerName :: !BS8.ByteString
, runtimeNoParsePath :: !Bool
}
deriving (Eq, Show)
data RunnerPlan
= PlainWarpRunner
| TlsWarpRunner
| QuicAndTlsRunner !Int
deriving (Eq, Show)
data LoadedCredential credential = LoadedCredential
{ loadedCredentialValue :: !credential
, loadedCredentialCertificateFingerprint :: !(Hash.Digest Hash.SHA256)
, loadedCredentialKeyFingerprint :: !(Hash.Digest Hash.SHA256)
}
deriving (Eq, Show)
type TlsCredentialLoader credential =
CertFile -> IO (Either String (LoadedCredential credential))
data TlsCredentialStore credential = TlsCredentialStore
{ tlsCredentialStoreSources :: ![(String, CertFile)]
, tlsCredentialStoreSnapshot :: !(IORef [(String, LoadedCredential credential)])
, tlsCredentialStoreReloadLock :: !(MVar ())
, tlsCredentialStoreLoader :: !(TlsCredentialLoader credential)
}
buildRuntimeConfig :: Config -> RuntimeConfig
buildRuntimeConfig Config{..} = RuntimeConfig
{ runtimeConfigLogOutput = parseLogOutput _log
, runtimeConfigReverseRoutes = map fromReverseRouteTuple revSorted
, runtimeConfigReverseRouteTuples = revSorted
}
where
revSorted = sortOn (\(a,b,_) -> Down (isJust a, BS8.length b)) _rev
buildProxySettings :: RuntimeConfig -> Config -> Logger -> Maybe (BS8.ByteString -> Bool) -> Bool -> ProxySettings
buildProxySettings RuntimeConfig{..} Config{..} logger pauth isSSL = ProxySettings
{ proxyAuth = pauth
, passPrompt = Just _name
, wsRemote = _ws
, revRemoteMap = runtimeConfigReverseRoutes
, hideProxyAuth = _hide
, naivePadding = _naive && isSSL
, acmeThumbprint = _acme
, logger = logger
}
buildProxyApplication :: Bool -> ProxySettings -> HC.Manager -> Application -> Application
buildProxyApplication isSSL pset manager fallback =
healthCheckProvider $
acmeProvider pset $
(if isSSL then forceSSL pset else id) $
httpProxy pset manager $
reverseProxy pset manager fallback
selectRunnerPlan :: Config -> [(String, a)] -> RunnerPlan
#ifdef QUIC_ENABLED
selectRunnerPlan Config{..} certs = case certs of
[] -> PlainWarpRunner
_ -> maybe TlsWarpRunner QuicAndTlsRunner _quic
#else
selectRunnerPlan _ certs = case certs of
[] -> PlainWarpRunner
_ -> TlsWarpRunner
#endif
validateRuntimeConfig :: Config -> Either String ()
validateRuntimeConfig Config{..} = do
validatePortField "--port" _port
#ifdef QUIC_ENABLED
maybe (Right ()) (validatePortField "--quic") _quic
#endif
validatePortField :: String -> Int -> Either String ()
validatePortField field port
| port >= 1 && port <= 65535 = Right ()
| otherwise = Left $ "invalid " <> field <> ": " <> show port <> " (expected 1..65535)"
runProxyServer
:: Config
-> Logger
-> Settings
-> TlsCredentialStore TLS.Credential
-> Application
-> IO ()
runProxyServer conf@Config{..} logger settings credentialStore app = do
logger INFO $ "bind to TCP port " <> toLogStr (fromMaybe "[::]" _bind) <> ":" <> toLogStr _port
case _doh of
Nothing -> runner app
Just doh -> createResolver doh (\resolver -> runner (dnsOverHTTPS resolver app))
where
runner = case selectRunnerPlan conf (tlsCredentialStoreSources credentialStore) of
PlainWarpRunner -> runSettings settings
TlsWarpRunner ->
runTLS (buildTlsSettings lookupSNICredentials') settings
#ifdef QUIC_ENABLED
QuicAndTlsRunner qport ->
runQuicAndTls logger _bind settings (buildTlsSettings lookupSNICredentials') lookupSNICredentials' qport
#else
QuicAndTlsRunner _ ->
runTLS (buildTlsSettings lookupSNICredentials') settings
#endif
lookupSNICredentials' host = lookupSNICredentialsFromStore host credentialStore
buildWarpRuntimePlan :: Config -> WarpRuntimePlan
buildWarpRuntimePlan Config{..} = WarpRuntimePlan
{ runtimeBindHost = fromMaybe "*6" _bind
, runtimePort = _port
, runtimeServerName = _name
, runtimeNoParsePath = True
}
buildWarpSettings :: Config -> Logger -> Maybe (IO ()) -> Settings
buildWarpSettings config logger beforeMainLoop =
applyBeforeMainLoop $
setHost (fromString (runtimeBindHost plan)) $
setPort (runtimePort plan) $
setLogger (warpAccessLogger logger) $
setOnException (runtimeExceptionHandler logger (_loglevel config)) $
setNoParsePath (runtimeNoParsePath plan) $
setServerName (runtimeServerName plan) defaultSettings
where
plan = buildWarpRuntimePlan config
applyBeforeMainLoop = maybe id setBeforeMainLoop beforeMainLoop
runtimeExceptionHandler :: Logger -> LogLevel -> Maybe Request -> SomeException -> IO ()
runtimeExceptionHandler logger logLevel req ex =
case runtimeExceptionToLog logLevel ex of
Nothing -> return ()
Just ex' ->
logger DEBUG $ "exception: " <> toLogStr (displayException ex') <>
maybe "" (\req' -> " from: " <> logRequest req') req
warpAccessLogger :: Logger -> Request -> HT.Status -> Maybe Integer -> IO ()
warpAccessLogger logger req status _
| shouldSuppressAccessLog req = return ()
| otherwise =
logger TRACE $ "(" <> toLogStr (HT.statusCode status) <> ") " <> logRequest req
shouldSuppressAccessLog :: Request -> Bool
shouldSuppressAccessLog req = rawPathInfo req == "/.hprox/health"
runtimeExceptionToLog :: LogLevel -> SomeException -> Maybe SomeException
runtimeExceptionToLog logLevel ex
| logLevel > DEBUG = Nothing
| not (defaultShouldDisplayException ex) = Nothing
| Just ioe <- fromException ex
, ioeGetErrorType ioe == EOF = Nothing
| Just (H2.BadThingHappen ex') <- fromException ex = runtimeExceptionToLog logLevel ex'
| Just (_ :: H2.HTTP2Error) <- fromException ex = Nothing
#ifdef QUIC_ENABLED
| Just (Q.BadThingHappen ex') <- fromException ex = runtimeExceptionToLog logLevel ex'
| Just (_ :: Q.QUICException) <- fromException ex = Nothing
#endif
| Just (_ :: WarpTLSException) <- fromException ex = Nothing
| Just ConnectionClosedByPeer <- fromException ex = Nothing
| otherwise = Just ex
loadTlsCredentialStore :: [(String, CertFile)] -> IO (TlsCredentialStore TLS.Credential)
loadTlsCredentialStore = newTlsCredentialStore loadTlsCredential
loadTlsCredentials :: [(String, CertFile)] -> IO [(String, TLS.Credential)]
loadTlsCredentials certFiles = readTlsCredentialStore =<< loadTlsCredentialStore certFiles
newTlsCredentialStore
:: TlsCredentialLoader credential
-> [(String, CertFile)]
-> IO (TlsCredentialStore credential)
newTlsCredentialStore loader certFiles = do
loadedCredentials <- mapM (loadInitialTlsCredential loader) certFiles
snapshot <- newIORef loadedCredentials
reloadLock <- newMVar ()
return TlsCredentialStore
{ tlsCredentialStoreSources = certFiles
, tlsCredentialStoreSnapshot = snapshot
, tlsCredentialStoreReloadLock = reloadLock
, tlsCredentialStoreLoader = loader
}
readTlsCredentialStore :: TlsCredentialStore credential -> IO [(String, credential)]
readTlsCredentialStore TlsCredentialStore{..} =
map (fmap loadedCredentialValue) <$> readIORef tlsCredentialStoreSnapshot
reloadTlsCredentialStore :: Logger -> TlsCredentialStore credential -> IO ()
reloadTlsCredentialStore logger store@TlsCredentialStore{..} =
withMVar tlsCredentialStoreReloadLock $ \() -> do
logger INFO "SIGHUP received; reloading TLS certificates"
oldSnapshot <- readIORef tlsCredentialStoreSnapshot
newSnapshot <- zipWithM (reloadTlsCredential logger store) tlsCredentialStoreSources oldSnapshot
atomicWriteIORef tlsCredentialStoreSnapshot newSnapshot
logger INFO "TLS certificate reload completed"
loadInitialTlsCredential
:: TlsCredentialLoader credential
-> (String, CertFile)
-> IO (String, LoadedCredential credential)
loadInitialTlsCredential loader (name, certFile) = do
loaded <- try (loader certFile)
case loaded of
Left (err :: IOException) -> failWithTlsCredentialContext name certFile $ displayException err
Right (Left err) -> failWithTlsCredentialContext name certFile err
Right (Right credential) -> return (name, credential)
reloadTlsCredential
:: Logger
-> TlsCredentialStore credential
-> (String, CertFile)
-> (String, LoadedCredential credential)
-> IO (String, LoadedCredential credential)
reloadTlsCredential logger TlsCredentialStore{..} (name, certFile) (_, oldCredential) = do
loaded <- try (tlsCredentialStoreLoader certFile)
case loaded of
Left (err :: IOException) -> reloadFailed $ displayException err
Right (Left err) -> reloadFailed err
Right (Right credential)
| loadedCredentialBytesChanged oldCredential credential -> do
logger INFO $ "installed reloaded TLS credential for " <> toLogStr (show name) <>
" (certificate: " <> toLogStr (certfile certFile) <>
", key: " <> toLogStr (keyfile certFile) <> ")"
return (name, credential)
| otherwise -> return (name, oldCredential)
where
reloadFailed err = do
logTlsCredentialReloadFailure logger name certFile err
return (name, oldCredential)
loadedCredentialBytesChanged :: LoadedCredential credential -> LoadedCredential credential -> Bool
loadedCredentialBytesChanged oldCredential newCredential =
loadedCredentialCertificateFingerprint oldCredential /= loadedCredentialCertificateFingerprint newCredential ||
loadedCredentialKeyFingerprint oldCredential /= loadedCredentialKeyFingerprint newCredential
loadTlsCredential :: TlsCredentialLoader TLS.Credential
loadTlsCredential (CertFile cert key) = do
certBytes <- BS.readFile cert
keyBytes <- BS.readFile key
return $ loadTlsCredentialFromMemory certBytes keyBytes
loadTlsCredentialFromMemory :: BS.ByteString -> BS.ByteString -> Either String (LoadedCredential TLS.Credential)
loadTlsCredentialFromMemory certBytes keyBytes =
case TLS.credentialLoadX509FromMemory certBytes keyBytes of
Left err -> Left err
Right credential@(TLS.CertificateChain chain, _)
| null chain -> Left "loaded TLS credential has an empty certificate chain"
| otherwise -> Right LoadedCredential
{ loadedCredentialValue = credential
, loadedCredentialCertificateFingerprint = fingerprintBytes certBytes
, loadedCredentialKeyFingerprint = fingerprintBytes keyBytes
}
fingerprintBytes :: BS.ByteString -> Hash.Digest Hash.SHA256
fingerprintBytes = Hash.hash
failWithTlsCredentialContext :: String -> CertFile -> String -> IO a
failWithTlsCredentialContext name (CertFile cert key) err =
ioError $ userError $
"failed to load TLS credential for " ++ show name ++
" (certificate: " ++ cert ++ ", key: " ++ key ++ "): " ++ err
logTlsCredentialReloadFailure :: Logger -> String -> CertFile -> String -> IO ()
logTlsCredentialReloadFailure logger name (CertFile cert key) err =
logger ERROR $
"failed to reload TLS credential for " <> toLogStr (show name) <>
" (certificate: " <> toLogStr cert <>
", key: " <> toLogStr key <> "): " <> toLogStr err
buildTlsSettings :: (Maybe String -> IO TLS.Credentials) -> TLSSettings
buildTlsSettings onSNI = defaultTlsSettings
{ tlsServerHooks = def { TLS.onServerNameIndication = onSNI }
, tlsCredentials = Just (TLS.Credentials [])
, onInsecure = AllowInsecure
, tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsSessionManager = Nothing
}
lookupSNICredentials :: Maybe String -> [(String, TLS.Credential)] -> IO TLS.Credentials
lookupSNICredentials host certs =
either fail (return . TLS.Credentials . (: [])) (lookupSNIHost host certs)
lookupSNICredentialsFromStore :: Maybe String -> TlsCredentialStore TLS.Credential -> IO TLS.Credentials
lookupSNICredentialsFromStore host store =
TLS.Credentials . (: []) <$> lookupTlsCredentialStore host store
lookupTlsCredentialStore :: Maybe String -> TlsCredentialStore credential -> IO credential
lookupTlsCredentialStore host TlsCredentialStore{..} = do
snapshot <- readIORef tlsCredentialStoreSnapshot
case host of
Nothing ->
case snapshot of
[] -> fail "SNI: no TLS credentials configured"
(_, value) : _ -> return $ loadedCredentialValue value
Just _ ->
either fail (return . loadedCredentialValue) (lookupSNIHost host snapshot)
lookupSNIHost :: Maybe String -> [(String, a)] -> Either String a
lookupSNIHost Nothing _ = Left "SNI: unspecified"
lookupSNIHost (Just host) certs = go certs
where
go [] = Left $ "SNI: unknown hostname (" ++ show host ++ ")"
go ((pattern, value) : rest)
| sniPatternMatches host pattern = Right value
| otherwise = go rest
sniPatternMatches :: String -> String -> Bool
sniPatternMatches host pattern = case map asciiLower pattern of
'*' : '.' : suffix -> singleLabelWildcardMatches (map asciiLower host) suffix
exact -> map asciiLower host == exact
singleLabelWildcardMatches :: String -> String -> Bool
singleLabelWildcardMatches host suffix =
case break (== '.') host of
([], _) -> False
(label, '.' : rest) -> not (null label) && rest == suffix
_ -> False
asciiLower :: Char -> Char
asciiLower char
| char >= 'A' && char <= 'Z' = toEnum (fromEnum char + 32)
| otherwise = char