hprox-0.4.0: src/Network/HProx.hs
-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2023 Bin Jin. All Rights Reserved.
{-| Instead of running @hprox@ binary directly, you can use this library
to run HProx in front of arbitrary WAI 'Application'.
-}
module Network.HProx
( CertFile (..)
, Config (..)
, defaultConfig
, getConfig
, run
) where
import Data.ByteString.Char8 qualified as BS8
import Data.List (isSuffixOf, (\\))
import Data.String (fromString)
import Data.Version (showVersion)
import Network.HTTP.Client.TLS (newTlsManager)
import Network.TLS qualified as TLS
import Network.TLS.Extra.Cipher qualified as TLS
import Network.Wai (Application, modifyResponse)
import Network.Wai.Handler.Warp
(HostPreference, defaultSettings, runSettings, setBeforeMainLoop, setHost,
setNoParsePath, setOnException, setPort, setServerName)
import Network.Wai.Handler.WarpTLS
(OnInsecure (..), onInsecure, runTLS, tlsAllowedVersions, tlsCiphers,
tlsServerHooks, tlsSettings)
import Network.Wai.Middleware.Gzip (def, gzip)
import Network.Wai.Middleware.StripHeaders (stripHeaders)
import System.Posix.User
(UserEntry (..), getUserEntryForName, setUserID)
import Data.Maybe
import Options.Applicative
import Network.HProx.DoH
import Network.HProx.Impl
(ProxySettings (..), forceSSL, httpProxy, reverseProxy)
import Paths_hprox (version)
-- | Configuration of HProx, see @hprox --help@ for details
data Config = Config
{ _bind :: Maybe HostPreference
, _port :: Int
, _ssl :: [(String, CertFile)]
, _user :: Maybe String
, _auth :: Maybe FilePath
, _ws :: Maybe String
, _rev :: Maybe String
, _doh :: Maybe String
, _naive :: Bool
}
-- | Default value of 'Config', same as running @hprox@ without arguments
defaultConfig :: Config
defaultConfig = Config Nothing 3000 [] Nothing Nothing Nothing Nothing Nothing False
-- | Certificate file pairs
data CertFile = CertFile
{ certfile :: FilePath
, keyfile :: FilePath
}
readCert :: CertFile -> IO TLS.Credential
readCert (CertFile c k) = either error id <$> TLS.credentialLoadX509 c k
splitBy :: Eq a => a -> [a] -> [[a]]
splitBy _ [] = [[]]
splitBy c (x:xs)
| c == x = [] : splitBy c xs
| otherwise = let y:ys = splitBy c xs in (x:y):ys
parser :: ParserInfo Config
parser = info (helper <*> ver <*> config) (fullDesc <> progDesc desc)
where
parseSSL s = case splitBy ':' s of
[host, cert, key] -> Right (host, CertFile cert key)
_ -> Left "invalid format for ssl certificates"
desc = "a lightweight HTTP proxy server, and more"
ver = infoOption (showVersion version) (long "version" <> help "show version")
config = Config <$> bind
<*> (fromMaybe 3000 <$> port)
<*> ssl
<*> user
<*> auth
<*> ws
<*> rev
<*> doh
<*> naive
bind = optional $ fromString <$> strOption
( long "bind"
<> short 'b'
<> metavar "bind_ip"
<> help "ip address to bind on (default: all interfaces)")
port = optional $ option auto
( long "port"
<> short 'p'
<> metavar "port"
<> help "port number (default 3000)")
ssl = many $ option (eitherReader parseSSL)
( long "tls"
<> short 's'
<> metavar "hostname:cerfile:keyfile"
<> help "enable TLS and specify a domain and associated TLS certificate (can be specified multiple times for multiple domains)")
user = optional $ strOption
( long "user"
<> short 'u'
<> metavar "nobody"
<> help "setuid after binding port")
auth = optional $ strOption
( long "auth"
<> short 'a'
<> metavar "userpass.txt"
<> help "password file for proxy authentication (plain text file with lines each containing a colon separated user/password pair)")
ws = optional $ strOption
( long "ws"
<> metavar "remote-host:port"
<> help "remote host to handle websocket requests (port 443 indicates HTTPS remote server)")
rev = optional $ strOption
( long "rev"
<> metavar "remote-host:port"
<> help "remote host for reverse proxy (port 443 indicates HTTPS remote server)")
doh = optional $ strOption
( long "doh"
<> metavar "dns-server:port"
<> help "enable DNS-over-HTTPS(DoH) support (53 will be used if port is not specified)")
naive = switch
( long "naive"
<> help "add naiveproxy compatible padding (requires TLS)")
setuid :: String -> IO ()
setuid user = getUserEntryForName user >>= setUserID . userID
-- | Read 'Config' from command line arguments
getConfig :: IO Config
getConfig = execParser parser
-- | Run HProx in front of fallback 'Application', with specified 'Config'
run :: Application -- ^ fallback application
-> Config -- ^ configuration
-> IO ()
run fallback Config{..} = do
let certfiles = _ssl
certs <- mapM (readCert.snd) certfiles
let isSSL = not (null certfiles)
(primaryHost, primaryCert) = head certfiles
otherCerts = tail $ zip (map fst certfiles) certs
settings = setHost (fromMaybe "*6" _bind) $
setPort _port $
setOnException (\_ _ -> return ()) $
setNoParsePath True $
setServerName "Apache" $
maybe id (setBeforeMainLoop . setuid) _user
defaultSettings
tlsset' = tlsSettings (certfile primaryCert) (keyfile primaryCert)
hooks = (tlsServerHooks tlsset') { TLS.onServerNameIndication = onSNI }
-- https://www.ssllabs.com/ssltest
weak_ciphers = [ TLS.cipher_ECDHE_RSA_AES256CBC_SHA384
, TLS.cipher_ECDHE_RSA_AES256CBC_SHA
, TLS.cipher_AES256CCM_SHA256
, TLS.cipher_AES256GCM_SHA384
, TLS.cipher_AES256_SHA256
, TLS.cipher_AES256_SHA1
]
tlsset = tlsset'
{ tlsServerHooks = hooks
, onInsecure = AllowInsecure
, tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = TLS.ciphersuite_strong \\ weak_ciphers
}
onSNI Nothing = fail "SNI: unspecified"
onSNI (Just host)
| checkSNI host primaryHost = return mempty
| otherwise = lookupSNI host otherCerts
lookupSNI host [] = fail ("SNI: unknown hostname (" ++ show host ++ ")")
lookupSNI host ((p, cert) : cs)
| checkSNI host p = return (TLS.Credentials [cert])
| otherwise = lookupSNI host cs
checkSNI host pat = case pat of
'*' : '.' : p -> ('.' : p) `isSuffixOf` host
p -> host == p
runner | isSSL = runTLS tlsset
| otherwise = runSettings
pauth <- case _auth of
Nothing -> return Nothing
Just f -> Just . flip elem . filter (isJust . BS8.elemIndex ':') . BS8.lines <$> BS8.readFile f
manager <- newTlsManager
let pset = ProxySettings pauth Nothing (BS8.pack <$> _ws) (BS8.pack <$> _rev) (_naive && isSSL)
proxy = (if isSSL then forceSSL pset else id) $
modifyResponse (stripHeaders ["Server", "Date"]) $
gzip def $
httpProxy pset manager $
reverseProxy pset manager fallback
case _doh of
Nothing -> runner settings proxy
Just doh -> createResolver doh (\resolver -> runner settings (dnsOverHTTPS resolver proxy))