{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DerivingVia #-}
module Main where
import qualified Avahi
import qualified Data.Base16.Types as B16
import qualified Data.Binary.Builder as Binary.Builder
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as BS8
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Map.Strict as Map
import qualified Data.Ron as Ron
import qualified Data.String
import qualified Data.Text as Text
import qualified Network.HTTP.Client as Client
import qualified Network.HTTP.Client.TLS as Client.Tls
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import qualified Portal
import qualified Static
import qualified System.Directory
import qualified System.Environment
import qualified System.Exit
import qualified System.FilePath
import qualified System.IO
import qualified Text.URI as Uri
import Control.Exception (handle)
import Control.Monad (forM_, when)
import Data.ByteString (ByteString)
import Data.Function (fix, (&))
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Data.HashMap.Strict (HashMap)
import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import GHC.Generics (Generic)
import System.IO.Unsafe (unsafePerformIO)
data IconType
= -- | Icon on the disk on this machine
LocalFile !Text
| -- | Remote icon which will be downloaded first
Url !Text
deriving (Eq, Show, Generic)
deriving
(Ron.FromRon)
via Ron.RonWith '[] IconType
logLock :: MVar ()
logLock = unsafePerformIO $ newMVar ()
{-# NOINLINE logLock #-}
logMessage :: String -> IO ()
logMessage s = withMVar logLock $ \() -> putStrLn s
onWarpException mbReq e = withMVar logLock $ \() -> Warp.defaultOnException mbReq e
data Service = Service
{ port :: !Int
-- ^ Port that the service is listening on
, title :: !(Maybe Text)
-- ^ Service title to show to user
, description :: !(Maybe Text)
-- ^ Optional descripction to show to user
, icon :: !(Maybe IconType)
-- ^ Optional service icon
}
deriving (Eq, Show, Generic)
deriving
(Ron.FromRon)
via Ron.RonWith
'[ Ron.EncodeWith Ron.SkipSingleConstructor
, Ron.DecodeWith Ron.ImplicitSome
]
Service
-- | Mapping of URLs to services. Services with these descriptions will be
-- created on these local domains
newtype ServicesConfig = ServicesConfig {getServicesConfig :: HashMap ByteString Service}
deriving (Eq, Show)
instance Ron.FromRon ServicesConfig where
fromRon = fmap (ServicesConfig . HashMap.fromList . Map.toList) . Ron.fromRon
data Settings = Settings
{ bindAddress :: !String
, bindPort :: !Int
, servicesPath :: !FilePath
, staticPath :: !FilePath
}
deriving (Eq, Show)
defaultSettings :: Settings
defaultSettings =
Settings
{ bindAddress = "127.0.0.1"
, bindPort = 3000
, servicesPath = "./services.ron"
, staticPath = "/etc/dwierz/static"
}
-- Ignores incorrect arguments, doesn't give help
parseCommandLine :: [String] -> Settings -> Settings
parseCommandLine = go
where
go [] !x = x
go ("--bind" : host : rest) !x = go rest $! x{bindAddress = host}
go (('-' : '-' : 'b' : 'i' : 'n' : 'd' : '=' : host) : rest) !x = go rest $! x{bindAddress = host}
go ("--port" : port : rest) !x = go rest $! x{bindPort = read port}
go (('-' : '-' : 'p' : 'o' : 'r' : 't' : '=' : port) : rest) !x = go rest $! x{bindPort = read port}
go ("--services" : services : rest) !x = go rest $! x{servicesPath = services}
go (('-' : '-' : 's' : 'e' : 'r' : 'v' : 'i' : 'c' : 'e' : 's' : '=' : services) : rest) !x = go rest $! x{servicesPath = services}
go ("--static" : static : rest) !x = go rest $! x{staticPath = static}
go (('-' : '-' : 's' : 't' : 'a' : 't' : 'i' : 'c' : '=' : static) : rest) !x = go rest $! x{staticPath = static}
go (_noMatch : rest) !x = go rest x
commandLineHelp :: String
commandLineHelp =
unlines
[ "Usage: dwierz [ARGS]"
, ""
, "\t--bind - hostname to bind to in the weird warp format. '*' means any ipv4 or ipv6 host. Default: '*'"
, ""
, "\t--port - TCP port to bind to. Anything other than 80 will most likely give unexpected results. Default: 80"
, ""
, "\t--services - file with services configuration. Default: '/etc/dwierz/services.ron'"
, ""
, "\t--static - path to the static files root directory. Default: '/etc/dwierz/static'"
]
data App = App
{ servicesConfig :: !ServicesConfig
, httpManager :: !Client.Manager
, portalPage :: !ByteString
, staticRoot :: !FilePath
}
reverseProxyApp :: App -> Wai.Application
reverseProxyApp App{portalPage, staticRoot, httpManager, servicesConfig = ServicesConfig config} incomingRequest respond = do
logMessage $ "Got request:\n" <> show incomingRequest <> "\n"
let mbService = (HashMap.!?) config =<< Wai.requestHeaderHost incomingRequest
case mbService of
Just Service{port} -> proxyTo port httpManager incomingRequest respond
Nothing
| null $ Wai.pathInfo incomingRequest ->
respond $ Wai.responseLBS (toEnum 200) mempty $ BS.fromStrict portalPage
| otherwise -> -- Static.serveStatic staticRoot (Wai.pathInfo incomingRequest) respond
let Service{port} = snd . head . HashMap.toList $ config
in proxyTo port httpManager incomingRequest respond
proxyTo :: Int -> Client.Manager -> Wai.Request -> (Wai.Response -> IO a) -> IO a
proxyTo targetPort manager incomingRequest respond = do
let streamsRequestBody = case Wai.requestBodyLength incomingRequest of
Wai.KnownLength len -> Client.RequestBodyStream $ fromIntegral len
Wai.ChunkedBody -> Client.RequestBodyStreamChunked
streamsRequestBody
:: ((Client.Popper -> IO ()) -> IO ()) -> Client.RequestBody
let requestBody =
streamsRequestBody $ \needsPopper ->
needsPopper $ Wai.getRequestBodyChunk incomingRequest
let request =
Client.defaultRequest
{ Client.method = Wai.requestMethod incomingRequest
, Client.secure = False
, Client.host = "localhost"
, Client.port = targetPort
, Client.path = Wai.rawPathInfo incomingRequest
, Client.queryString = Wai.rawQueryString incomingRequest
, Client.requestHeaders =
map (fixHost . fixReferer)
. filter (not . strippedHeader)
$ Wai.requestHeaders incomingRequest
, Client.requestBody = requestBody
, Client.proxy = Nothing
, Client.decompress = const False
, Client.redirectCount = 0
, Client.cookieJar = Nothing
, Client.requestVersion = Wai.httpVersion incomingRequest
}
Client.withResponse request manager $ \resp -> do
logMessage $ "Upstream response:\n" <> show resp{Client.responseBody = "<lazy body>" :: String} <> "\n"
let status = Client.responseStatus resp
let headers = Client.responseHeaders resp
let nextBodyChunk = Client.responseBody resp
respond $ Wai.responseStream status headers $ \sendChunk flush ->
let sendChunk' = sendChunk . Binary.Builder.fromByteString
in resendFlushingBody nextBodyChunk sendChunk' flush
where
strippedHeader (k, v) =
k
`elem` [ "accept-encoding"
, "content-encoding"
, "content-length"
, "transfer-encoding"
]
|| k == "connection" && v == "close"
fixHost (k, v)
| k == "host" = (k, "localhost:" <> BS8.pack (show targetPort))
| otherwise = (k, v)
fixReferer (k, v)
| k == "referer" || k == "origin" = (k, changeUriHost (fromIntegral targetPort) v)
| otherwise = (k, v)
changeUriHost :: Word -> ByteString -> ByteString
changeUriHost port bs = case parseUri bs of
Left _e -> bs
Right uri ->
let auth = case Uri.uriAuthority uri of
Left b -> Left b
Right x ->
Right
x
{ Uri.authHost = localhost
, Uri.authPort = Just port
}
in renderUri uri{Uri.uriAuthority = auth}
where
parseUri = Uri.mkURIBs
localhost = case Uri.mkHost "localhost" of
Left e -> error $ "Error parsing localhost constant: " <> show e
Right x -> x
renderUri :: Uri.URI -> ByteString
renderUri = Uri.renderBs
resendFlushingBody :: IO ByteString -> (ByteString -> IO ()) -> IO () -> IO ()
resendFlushingBody getNext sendNext flush = go 0
where
go !len
| len >= 4096 = flush >> go 0
| otherwise = do
chunk <- getNext
if BS.length chunk == 0
then flush >> pure ()
else sendNext chunk >> go (len + BS.length chunk)
renderService :: (ByteString, Service) -> Portal.ServiceDefinition
renderService (domain, s) =
Portal.ServiceDefinition
{ Portal.url = url
, Portal.title = fromMaybe url (encodeUtf8 <$> title s)
, Portal.description = fromMaybe "" (encodeUtf8 <$> description s)
, Portal.icon = resolvedIcon
}
where
url = "http://" <> domain
resolvedIcon = flip fmap (icon s) $ \case
LocalFile x -> encodeUtf8 x
Url x -> encodeRemoteUrl x
encodeRemoteUrl :: Text -> ByteString
encodeRemoteUrl url =
"_downloaded/base16-"
<> (B16.extractBase16 . B16.encodeBase16' . encodeUtf8 $ url)
<> (BS8.pack . System.FilePath.takeExtension . Text.unpack $ url)
downloadFile :: Client.Manager -> Text -> FilePath -> IO ()
downloadFile manager url staticRoot = handle onHttpException $ do
let destPath = staticRoot <> "/" <> (Text.unpack . decodeUtf8 $ encodeRemoteUrl url)
request <- Client.parseRequest . Text.unpack $ url
Client.withResponse request manager $ \resp ->
if Client.responseStatus resp /= toEnum 200
then putStrLn $ "Non-ok response for " <> show url
else System.IO.withFile destPath System.IO.WriteMode $ \h ->
fix $ \again -> do
chunk <- Client.brRead . Client.responseBody $ resp
if BS.null chunk
then pure ()
else do
BS.hPut h chunk
again
where
onHttpException :: Client.HttpException -> IO ()
onHttpException e = putStrLn $ "Failed to download " <> show url <> "; reason: " <> show e
main :: IO ()
main = do
args <-
System.Environment.getArgs >>= \case
("--help" : _) ->
putStrLn commandLineHelp
>> System.Exit.exitWith System.Exit.ExitSuccess
x -> pure x
let appSettings@Settings{bindAddress, bindPort, servicesPath, staticPath} =
parseCommandLine args defaultSettings
putStrLn $ "App settings: " <> show appSettings
servicesConfig <- Ron.decodeFile servicesPath
putStrLn $ "Read services: " <> show servicesConfig
let portalServices = map renderService . HashMap.toList . getServicesConfig $ servicesConfig
let portalPage = Portal.makePortal portalServices
-- create mdns domains
avahiClient <- Avahi.createClient
myHostname <- Avahi.getHostName avahiClient
putStrLn $ "Hostname: " <> show myHostname
httpManager <- Client.Tls.newTlsManager
-- download all remote icons
System.Directory.createDirectoryIfMissing True $ staticPath <> "/_downloaded"
let icons =
catMaybes
. map (\case Url x -> Just x; _ -> Nothing)
. catMaybes
. map icon
. HashMap.elems
. getServicesConfig
$ servicesConfig
forM_ icons $ \iconUrl -> do
let path = staticPath <> "/" <> (Text.unpack . decodeUtf8 $ encodeRemoteUrl iconUrl)
exists <- System.Directory.doesFileExist path
when (not exists) $ do
putStrLn $ "Downloading " <> show iconUrl
downloadFile httpManager iconUrl staticPath
putStrLn "Starting HTTP server"
let app =
reverseProxyApp
App
{ servicesConfig
, httpManager
, portalPage
, staticRoot = staticPath
}
let warpSettings =
Warp.defaultSettings
& Warp.setPort bindPort
& Warp.setHost (Data.String.fromString bindAddress)
& Warp.setOnException onWarpException
Warp.runSettings warpSettings app