From 998b5268e070f158021e268482be50081337a956 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 02:12:00 -0400 Subject: [PATCH] fix: close audit-pass-1 MAJOR systemic findings (Findings 7-13) Brings the older pre-ML modules up to the same standard as the newer ML pipeline. Closes Findings 7 (file headers), 8 (inline comments), 9 (NIH reimplementations), 11 (selectWeightedRR partial function + O(n^2)), 12 (weak LoadBalancer tests), 13 (placeholder Backend / ConnLimit tests), and 32 (locally-redefined HTTP status constants). Module rewrites with new file header + zero inline comments: - src/Aenebris/Proxy.hs - src/Aenebris/LoadBalancer.hs - src/Aenebris/HealthCheck.hs - src/Aenebris/Middleware/Security.hs - src/Aenebris/Middleware/Redirect.hs - src/Aenebris/Config.hs - src/Aenebris/Backend.hs - app/Main.hs Algorithmic + structural fixes: - LoadBalancer.selectWeightedRR no longer uses partial (!!) and a separate find/maximum/index pass; replaced with a single STM fold that tags each backend with its current weight and picks via Data.List.maximumBy + Data.Ord.comparing. O(n) instead of O(n^2), no Maybe fallback. - Proxy.proxyApp deduplicates the regular-HTTP code path that was copy-pasted across the WebSocket and `_` connection-type branches (Finding 16). Single forwardRegular helper called from both. - Proxy.logRequest removed; per-request stdout logging was inconsistent with the stderr error pattern used elsewhere and a perf hazard at scale (Finding 17). Defer real structured logging to Phase 4 per docs/status/MASTER_PLAN_2026.md. - Backend.recordFailure now increments rbTotalFailures uniformly across all states (Finding 22), not just Unhealthy. - Backend.Show instance now includes weight (Finding 21). - Connection.hs gains microsPerSecond and httpOkStatusCode named constants. tcUpstreamReadSeconds added to TimeoutConfig. - HealthCheck uses the shared microsPerSecond constant instead of inline 1000000 (Finding 18). Compares status against the named httpOkStatusCode constant. NIH reimplementations replaced with stdlib: - LoadBalancer: filterM, forM_ from Control.Monad; minimumBy, maximumBy from Data.List; comparing from Data.Ord. - Proxy: zipWithM from Control.Monad. - HealthCheck: zipWithM_ from Control.Monad. - Middleware.Security: catMaybes from Data.Maybe. - Config: nub from Data.List (replaces local nubText). - Fingerprint.JA4H, ML.Features: built-in lookup over [(CI ByteString, ByteString)] (CI ByteString already has Eq). - RateLimit, DDoS.ConnLimit: intercalate ":" from Data.List (replaces duplicated joinColons in two files). - Honeypot: status200, status404 from Network.HTTP.Types (drops local mkStatus duplications). - WAF.Engine: status403 from Network.HTTP.Types. Test strengthening (test/Spec.hs): - loadBalancerSpec: round-robin now asserts even distribution ([3,3,3] across 3 backends in 9 rounds); weighted RR asserts the 4-weight backend wins >= 35 of 50 picks; least-connections asserts the lower-count backend is selected after manually bumping the other to 5 active connections. - backendSpec: "tolerates repeated failures" replaced with a real state-transition test ('Healthy after 1 failure, Healthy after 2, Unhealthy after 3'). 'records successes without crashing' replaced with assertion that recordSuccess on Healthy resets rbConsecutiveFailures to 0. - connLimitSpec: 'release decrements counter' now reads currentCount before and after release to assert the count actually went 1 -> 0. Build clean, 358 examples passing, 0 failures, 0 GHC warnings on any touched module. Audit findings 14, 15, 18 (residual MAJOR) and 19-31 (MINOR) deferred to subsequent passes. --- .../haskell-reverse-proxy/app/Main.hs | 49 +- .../src/Aenebris/Backend.hs | 111 ++-- .../src/Aenebris/Config.hs | 268 ++++---- .../src/Aenebris/DDoS/ConnLimit.hs | 8 +- .../src/Aenebris/Fingerprint/JA4H.hs | 7 +- .../src/Aenebris/HealthCheck.hs | 79 +-- .../src/Aenebris/Honeypot.hs | 8 +- .../src/Aenebris/LoadBalancer.hs | 136 ++-- .../src/Aenebris/ML/Features.hs | 27 +- .../src/Aenebris/Middleware/Redirect.hs | 56 +- .../src/Aenebris/Middleware/Security.hs | 154 ++--- .../src/Aenebris/Proxy.hs | 616 +++++++++--------- .../src/Aenebris/RateLimit.hs | 8 +- .../src/Aenebris/WAF/Engine.hs | 5 +- .../haskell-reverse-proxy/test/Spec.hs | 77 ++- 15 files changed, 780 insertions(+), 829 deletions(-) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/app/Main.hs b/PROJECTS/advanced/haskell-reverse-proxy/app/Main.hs index 62d2ca7e..d557f60e 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/app/Main.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/app/Main.hs @@ -1,3 +1,7 @@ +{- +©AngelaMos | 2026 +Main.hs +-} {-# LANGUAGE OverloadedStrings #-} module Main (main) where @@ -19,43 +23,38 @@ import System.Environment (getArgs) import System.Exit (exitFailure) import System.IO (hPutStrLn, stderr) +defaultConfigPath :: FilePath +defaultConfigPath = "config.yaml" + main :: IO () main = do args <- getArgs - - -- Get config file path from args or use default let configPath = case args of (path:_) -> path - [] -> "config.yaml" + [] -> defaultConfigPath putStrLn $ "Loading configuration from: " ++ configPath result <- loadConfig configPath case result of Left err -> do - hPutStrLn stderr $ "ERROR: Failed to load configuration" + hPutStrLn stderr "ERROR: Failed to load configuration" hPutStrLn stderr err exitFailure - Right config -> do - case validateConfig config of - Left err -> do - hPutStrLn stderr $ "ERROR: Invalid configuration" - hPutStrLn stderr err - exitFailure + Right config -> case validateConfig config of + Left err -> do + hPutStrLn stderr "ERROR: Invalid configuration" + hPutStrLn stderr err + exitFailure - Right () -> do - putStrLn "Configuration loaded and validated successfully" - - let upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig - * microsPerSecond - managerSettings = defaultManagerSettings - { managerResponseTimeout = responseTimeoutMicro upstreamMicros - } - manager <- newManager managerSettings - - -- Initialize proxy state (load balancers + health checkers) - proxyState <- initProxyState config manager - - -- Start the proxy - startProxy proxyState + Right () -> do + putStrLn "Configuration loaded and validated successfully" + let upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig + * microsPerSecond + managerSettings = defaultManagerSettings + { managerResponseTimeout = responseTimeoutMicro upstreamMicros + } + manager <- newManager managerSettings + proxyState <- initProxyState config manager + startProxy proxyState diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Backend.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Backend.hs index d77062a8..127aaa5c 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Backend.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Backend.hs @@ -1,3 +1,7 @@ +{- +©AngelaMos | 2026 +Backend.hs +-} {-# LANGUAGE RecordWildCards #-} module Aenebris.Backend @@ -22,126 +26,125 @@ import Control.Monad (when) import Data.Text (Text) import Data.Time.Clock (UTCTime) +initialActiveConnections :: Int +initialActiveConnections = 0 + +initialCurrentWeight :: Int +initialCurrentWeight = 0 + +initialFailureCount :: Int +initialFailureCount = 0 + +initialSuccessCount :: Int +initialSuccessCount = 0 + +initialMetricCount :: Int +initialMetricCount = 0 + data BackendState = Healthy | Unhealthy | Recovering deriving (Eq, Show) --- | Runtime backend state wrapping config Server data RuntimeBackend = RuntimeBackend - { rbServerId :: Int -- Unique identifier - , rbHost :: Text - , rbWeight :: Int - -- Runtime state (STM) - , rbActiveConnections :: TVar Int - , rbCurrentWeight :: TVar Int - , rbHealthState :: TVar BackendState - , rbConsecutiveFailures :: TVar Int - , rbConsecutiveSuccesses :: TVar Int - , rbLastHealthCheck :: TVar (Maybe UTCTime) - , rbTotalRequests :: TVar Int -- For metrics - , rbTotalFailures :: TVar Int -- For metrics + { rbServerId :: !Int + , rbHost :: !Text + , rbWeight :: !Int + , rbActiveConnections :: !(TVar Int) + , rbCurrentWeight :: !(TVar Int) + , rbHealthState :: !(TVar BackendState) + , rbConsecutiveFailures :: !(TVar Int) + , rbConsecutiveSuccesses :: !(TVar Int) + , rbLastHealthCheck :: !(TVar (Maybe UTCTime)) + , rbTotalRequests :: !(TVar Int) + , rbTotalFailures :: !(TVar Int) } instance Show RuntimeBackend where - show rb = "RuntimeBackend {id=" ++ show (rbServerId rb) ++ - ", host=" ++ show (rbHost rb) ++ "}" + show rb = "RuntimeBackend {id=" + ++ show (rbServerId rb) + ++ ", host=" + ++ show (rbHost rb) + ++ ", weight=" + ++ show (rbWeight rb) + ++ "}" instance Eq RuntimeBackend where rb1 == rb2 = rbServerId rb1 == rbServerId rb2 --- | Runtime backend from config Server createRuntimeBackend :: Int -> Server -> IO RuntimeBackend -createRuntimeBackend serverId Server{..} = do - atomically $ RuntimeBackend serverId serverHost serverWeight - <$> newTVar 0 -- activeConnections - <*> newTVar 0 -- currentWeight (for smooth WRR) - <*> newTVar Healthy -- healthState - <*> newTVar 0 -- consecutiveFailures - <*> newTVar 0 -- consecutiveSuccesses - <*> newTVar Nothing -- lastHealthCheck - <*> newTVar 0 -- totalRequests - <*> newTVar 0 -- totalFailures +createRuntimeBackend serverId Server{..} = atomically $ + RuntimeBackend serverId serverHost serverWeight + <$> newTVar initialActiveConnections + <*> newTVar initialCurrentWeight + <*> newTVar Healthy + <*> newTVar initialFailureCount + <*> newTVar initialSuccessCount + <*> newTVar Nothing + <*> newTVar initialMetricCount + <*> newTVar initialMetricCount --- | Check if backend is healthy isHealthy :: RuntimeBackend -> STM Bool isHealthy rb = (== Healthy) <$> readTVar (rbHealthState rb) --- | Track a connection (increment on start, decrement on end) trackConnection :: RuntimeBackend -> IO a -> IO a trackConnection rb action = bracket_ (atomically $ do - modifyTVar' (rbActiveConnections rb) (+1) - modifyTVar' (rbTotalRequests rb) (+1)) + modifyTVar' (rbActiveConnections rb) (+ 1) + modifyTVar' (rbTotalRequests rb) (+ 1)) (atomically $ modifyTVar' (rbActiveConnections rb) (subtract 1)) action --- | Get current connection count getConnectionCount :: RuntimeBackend -> STM Int getConnectionCount rb = readTVar (rbActiveConnections rb) --- | Get current weight (for smooth weighted RR) getCurrentWeight :: RuntimeBackend -> STM Int getCurrentWeight rb = readTVar (rbCurrentWeight rb) --- | State transition: mark as unhealthy transitionToUnhealthy :: RuntimeBackend -> STM () transitionToUnhealthy rb = do writeTVar (rbHealthState rb) Unhealthy - writeTVar (rbConsecutiveFailures rb) 0 - writeTVar (rbConsecutiveSuccesses rb) 0 + writeTVar (rbConsecutiveFailures rb) initialFailureCount + writeTVar (rbConsecutiveSuccesses rb) initialSuccessCount --- | State transition: start recovering transitionToRecovering :: RuntimeBackend -> STM () transitionToRecovering rb = do writeTVar (rbHealthState rb) Recovering writeTVar (rbConsecutiveSuccesses rb) 1 --- | State transition: mark as healthy transitionToHealthy :: RuntimeBackend -> STM () transitionToHealthy rb = do writeTVar (rbHealthState rb) Healthy - writeTVar (rbConsecutiveFailures rb) 0 - writeTVar (rbConsecutiveSuccesses rb) 0 + writeTVar (rbConsecutiveFailures rb) initialFailureCount + writeTVar (rbConsecutiveSuccesses rb) initialSuccessCount --- | Record a health check failure recordFailure :: RuntimeBackend -> Int -> STM () recordFailure rb maxFailures = do + modifyTVar' (rbTotalFailures rb) (+ 1) state <- readTVar (rbHealthState rb) failures <- readTVar (rbConsecutiveFailures rb) - case state of Healthy -> do let newFailures = failures + 1 writeTVar (rbConsecutiveFailures rb) newFailures when (newFailures >= maxFailures) $ transitionToUnhealthy rb - - Recovering -> do - -- Failed during recovery, back to unhealthy + Recovering -> transitionToUnhealthy rb - Unhealthy -> - -- Already unhealthy, just record it - modifyTVar' (rbTotalFailures rb) (+1) + pure () --- | Record a health check success recordSuccess :: RuntimeBackend -> Int -> STM () recordSuccess rb recoveryAttempts = do state <- readTVar (rbHealthState rb) successes <- readTVar (rbConsecutiveSuccesses rb) - case state of Healthy -> - -- Reset failure counter - writeTVar (rbConsecutiveFailures rb) 0 - + writeTVar (rbConsecutiveFailures rb) initialFailureCount Unhealthy -> - -- First success, transition to recovering transitionToRecovering rb - Recovering -> do let newSuccesses = successes + 1 writeTVar (rbConsecutiveSuccesses rb) newSuccesses diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Config.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Config.hs index c2876f13..4446a717 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Config.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Config.hs @@ -1,3 +1,7 @@ +{- +©AngelaMos | 2026 +Config.hs +-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} @@ -20,51 +24,63 @@ module Aenebris.Config import Aenebris.Honeypot (HoneypotConfigYaml) import Aenebris.Geo (GeoConfigYaml) -import Control.Monad (when, forM_) +import Control.Monad (forM_, when) import Data.Aeson +import Data.List (nub) import Data.Text (Text) import qualified Data.Text as T import Data.Yaml (decodeFileEither) import GHC.Generics --- | Main config structure +supportedConfigVersion :: Int +supportedConfigVersion = 1 + +minPort :: Int +minPort = 1 + +maxPort :: Int +maxPort = 65535 + +minServerWeight :: Int +minServerWeight = 1 + data Config = Config - { configVersion :: Int - , configListen :: [ListenConfig] - , configUpstreams :: [Upstream] - , configRoutes :: [Route] - , configRateLimit :: Maybe Text - , configDDoS :: Maybe DDoSConfig - , configHoneypot :: Maybe HoneypotConfigYaml - , configGeo :: Maybe GeoConfigYaml + { configVersion :: !Int + , configListen :: ![ListenConfig] + , configUpstreams :: ![Upstream] + , configRoutes :: ![Route] + , configRateLimit :: !(Maybe Text) + , configDDoS :: !(Maybe DDoSConfig) + , configHoneypot :: !(Maybe HoneypotConfigYaml) + , configGeo :: !(Maybe GeoConfigYaml) } deriving (Show, Eq, Generic) instance FromJSON Config where parseJSON = withObject "Config" $ \v -> Config - <$> v .: "version" - <*> v .: "listen" - <*> v .: "upstreams" - <*> v .: "routes" + <$> v .: "version" + <*> v .: "listen" + <*> v .: "upstreams" + <*> v .: "routes" <*> v .:? "rate_limit" <*> v .:? "ddos" <*> v .:? "honeypot" <*> v .:? "geo" data DDoSConfig = DDoSConfig - { ddosEarlyDataReject :: Bool - , ddosPerIPConnections :: Maybe Int - , ddosMemoryShedBytes :: Maybe Integer - , ddosMemoryShedHighWater :: Maybe Double - , ddosMaxConcurrentStreams :: Maybe Int - , ddosMaxHeaderBytes :: Maybe Int - , ddosSlowlorisSeconds :: Maybe Int - , ddosJailCooldownSeconds :: Maybe Int - , ddosReusePort :: Bool + { ddosEarlyDataReject :: !Bool + , ddosPerIPConnections :: !(Maybe Int) + , ddosMemoryShedBytes :: !(Maybe Integer) + , ddosMemoryShedHighWater :: !(Maybe Double) + , ddosMaxConcurrentStreams :: !(Maybe Int) + , ddosMaxHeaderBytes :: !(Maybe Int) + , ddosSlowlorisSeconds :: !(Maybe Int) + , ddosJailCooldownSeconds :: !(Maybe Int) + , ddosReusePort :: !Bool } deriving (Show, Eq, Generic) instance FromJSON DDoSConfig where parseJSON = withObject "DDoSConfig" $ \v -> DDoSConfig - <$> v .:? "early_data_reject" .!= True + <$> v .:? "early_data_reject" .!= True <*> v .:? "per_ip_connections" <*> v .:? "memory_shed_bytes" <*> v .:? "memory_shed_high_water" @@ -72,41 +88,39 @@ instance FromJSON DDoSConfig where <*> v .:? "max_header_bytes" <*> v .:? "slowloris_seconds" <*> v .:? "jail_cooldown_seconds" - <*> v .:? "reuse_port" .!= False + <*> v .:? "reuse_port" .!= False defaultDDoSConfig :: DDoSConfig defaultDDoSConfig = DDoSConfig - { ddosEarlyDataReject = True - , ddosPerIPConnections = Nothing - , ddosMemoryShedBytes = Nothing - , ddosMemoryShedHighWater = Nothing + { ddosEarlyDataReject = True + , ddosPerIPConnections = Nothing + , ddosMemoryShedBytes = Nothing + , ddosMemoryShedHighWater = Nothing , ddosMaxConcurrentStreams = Nothing - , ddosMaxHeaderBytes = Nothing - , ddosSlowlorisSeconds = Nothing - , ddosJailCooldownSeconds = Nothing - , ddosReusePort = False + , ddosMaxHeaderBytes = Nothing + , ddosSlowlorisSeconds = Nothing + , ddosJailCooldownSeconds = Nothing + , ddosReusePort = False } --- | Listen port configuration data ListenConfig = ListenConfig - { listenPort :: Int - , listenTLS :: Maybe TLSConfig - , listenRedirectHTTPS :: Maybe Bool -- Redirect HTTP to HTTPS? + { listenPort :: !Int + , listenTLS :: !(Maybe TLSConfig) + , listenRedirectHTTPS :: !(Maybe Bool) } deriving (Show, Eq, Generic) instance FromJSON ListenConfig where parseJSON = withObject "ListenConfig" $ \v -> ListenConfig - <$> v .: "port" + <$> v .: "port" <*> v .:? "tls" <*> v .:? "redirect_https" --- | TLS/SSL configuration (supports both single cert and SNI) data TLSConfig = TLSConfig - { tlsCert :: Maybe FilePath -- Single cert (if not using SNI) - , tlsKey :: Maybe FilePath -- Single key (if not using SNI) - , tlsSNI :: Maybe [SNIDomain] -- SNI domains (multiple certs) - , tlsDefaultCert :: Maybe FilePath -- Default cert for SNI - , tlsDefaultKey :: Maybe FilePath -- Default key for SNI + { tlsCert :: !(Maybe FilePath) + , tlsKey :: !(Maybe FilePath) + , tlsSNI :: !(Maybe [SNIDomain]) + , tlsDefaultCert :: !(Maybe FilePath) + , tlsDefaultKey :: !(Maybe FilePath) } deriving (Show, Eq, Generic) instance FromJSON TLSConfig where @@ -117,11 +131,10 @@ instance FromJSON TLSConfig where <*> v .:? "default_cert" <*> v .:? "default_key" --- | SNI domain configuration data SNIDomain = SNIDomain - { sniDomain :: Text - , sniCert :: FilePath - , sniKey :: FilePath + { sniDomain :: !Text + , sniCert :: !FilePath + , sniKey :: !FilePath } deriving (Show, Eq, Generic) instance FromJSON SNIDomain where @@ -130,23 +143,21 @@ instance FromJSON SNIDomain where <*> v .: "cert" <*> v .: "key" --- | Upstream backend definition data Upstream = Upstream - { upstreamName :: Text - , upstreamServers :: [Server] - , upstreamHealthCheck :: Maybe HealthCheck + { upstreamName :: !Text + , upstreamServers :: ![Server] + , upstreamHealthCheck :: !(Maybe HealthCheck) } deriving (Show, Eq, Generic) instance FromJSON Upstream where parseJSON = withObject "Upstream" $ \v -> Upstream - <$> v .: "name" - <*> v .: "servers" + <$> v .: "name" + <*> v .: "servers" <*> v .:? "health_check" --- | Backend server with weight for load balancing data Server = Server - { serverHost :: Text - , serverWeight :: Int + { serverHost :: !Text + , serverWeight :: !Int } deriving (Show, Eq, Generic) instance FromJSON Server where @@ -154,10 +165,9 @@ instance FromJSON Server where <$> v .: "host" <*> v .: "weight" --- | Health check configuration data HealthCheck = HealthCheck - { healthCheckPath :: Text - , healthCheckInterval :: Text -- e.g., "10s" + { healthCheckPath :: !Text + , healthCheckInterval :: !Text } deriving (Show, Eq, Generic) instance FromJSON HealthCheck where @@ -165,10 +175,9 @@ instance FromJSON HealthCheck where <$> v .: "path" <*> v .: "interval" --- | Route definition (virtual host + paths) data Route = Route - { routeHost :: Text - , routePaths :: [PathRoute] + { routeHost :: !Text + , routePaths :: ![PathRoute] } deriving (Show, Eq, Generic) instance FromJSON Route where @@ -176,122 +185,99 @@ instance FromJSON Route where <$> v .: "host" <*> v .: "paths" --- | Path-based routing rule data PathRoute = PathRoute - { pathRoutePath :: Text - , pathRouteUpstream :: Text - , pathRouteRateLimit :: Maybe Text -- e.g., "100/minute" + { pathRoutePath :: !Text + , pathRouteUpstream :: !Text + , pathRouteRateLimit :: !(Maybe Text) } deriving (Show, Eq, Generic) instance FromJSON PathRoute where parseJSON = withObject "PathRoute" $ \v -> PathRoute - <$> v .: "path" - <*> v .: "upstream" + <$> v .: "path" + <*> v .: "upstream" <*> v .:? "rate_limit" --- | Load configuration from YAML file loadConfig :: FilePath -> IO (Either String Config) loadConfig path = do result <- decodeFileEither path - return $ case result of - Left err -> Left (show err) + pure $ case result of + Left err -> Left (show err) Right config -> Right config --- | Validate configuration for correctness validateConfig :: Config -> Either String () validateConfig config = do - -- Check version - when (configVersion config /= 1) $ - Left "Unsupported config version (expected: 1)" + when (configVersion config /= supportedConfigVersion) $ + Left ("Unsupported config version (expected: " + ++ show supportedConfigVersion ++ ")") - -- Check at least one listen port - when (null $ configListen config) $ + when (null (configListen config)) $ Left "At least one listen port must be specified" - -- Check port numbers are valid forM_ (configListen config) $ \listen -> do let port = listenPort listen - when (port < 1 || port > 65535) $ - Left $ "Invalid port number: " ++ show port - - -- Validate TLS configuration if present + when (port < minPort || port > maxPort) $ + Left ("Invalid port number: " ++ show port) case listenTLS listen of - Nothing -> return () - Just tlsConf -> validateTLS tlsConf + Nothing -> pure () + Just tlsConf -> validateTLS tlsConf - -- Check at least one upstream - when (null $ configUpstreams config) $ + when (null (configUpstreams config)) $ Left "At least one upstream must be specified" - -- Check upstream names are unique let upstreamNames = map upstreamName (configUpstreams config) - when (length upstreamNames /= length (nubText upstreamNames)) $ + when (length upstreamNames /= length (nub upstreamNames)) $ Left "Upstream names must be unique" - -- Check each upstream has at least one server forM_ (configUpstreams config) $ \upstream -> do - when (null $ upstreamServers upstream) $ - Left $ "Upstream '" ++ T.unpack (upstreamName upstream) ++ "' has no servers" + when (null (upstreamServers upstream)) $ + Left ("Upstream '" ++ T.unpack (upstreamName upstream) + ++ "' has no servers") + forM_ (upstreamServers upstream) $ \server -> + when (serverWeight server < minServerWeight) $ + Left ("Server weight must be positive: " + ++ T.unpack (serverHost server)) - -- Check server weights are positive - forM_ (upstreamServers upstream) $ \server -> do - when (serverWeight server < 1) $ - Left $ "Server weight must be positive: " ++ T.unpack (serverHost server) - - -- Check at least one route - when (null $ configRoutes config) $ + when (null (configRoutes config)) $ Left "At least one route must be specified" - -- Validate upstream references in routes forM_ (configRoutes config) $ \route -> do - when (null $ routePaths route) $ - Left $ "Route for host '" ++ T.unpack (routeHost route) ++ "' has no paths" - + when (null (routePaths route)) $ + Left ("Route for host '" ++ T.unpack (routeHost route) + ++ "' has no paths") forM_ (routePaths route) $ \pathRoute -> do let upstreamRef = pathRouteUpstream pathRoute when (upstreamRef `notElem` upstreamNames) $ - Left $ "Unknown upstream referenced: '" ++ T.unpack upstreamRef ++ "'" + Left ("Unknown upstream referenced: '" + ++ T.unpack upstreamRef ++ "'") - return () - where - -- Helper to remove duplicates from Text list - nubText :: [Text] -> [Text] - nubText [] = [] - nubText (x:xs) = x : nubText (filter (/= x) xs) + pure () - -- Validate TLS configuration - validateTLS :: TLSConfig -> Either String () - validateTLS tlsConf = do - let hasSingleCert = case (tlsCert tlsConf, tlsKey tlsConf) of - (Just _, Just _) -> True - (Nothing, Nothing) -> False - _ -> False -- One is set but not the other +validateTLS :: TLSConfig -> Either String () +validateTLS tlsConf = do + let hasSingleCert = case (tlsCert tlsConf, tlsKey tlsConf) of + (Just _, Just _) -> True + _ -> False + hasSNI = case (tlsSNI tlsConf, tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of + (Just sniDomains, Just _, Just _) -> not (null sniDomains) + _ -> False - hasSNI = case (tlsSNI tlsConf, tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of - (Just sniDomains, Just _, Just _) -> not (null sniDomains) - _ -> False + when (not hasSingleCert && not hasSNI) $ + Left "TLS configuration must specify either (cert + key) or (sni + default_cert + default_key)" - -- Must have either single cert or SNI configuration - when (not hasSingleCert && not hasSNI) $ - Left "TLS configuration must specify either (cert + key) or (sni + default_cert + default_key)" + when (hasSingleCert && hasSNI) $ + Left "TLS configuration cannot have both single cert and SNI configuration" - -- Can't have both single cert and SNI - when (hasSingleCert && hasSNI) $ - Left "TLS configuration cannot have both single cert and SNI configuration" + when hasSingleCert $ + case (tlsCert tlsConf, tlsKey tlsConf) of + (Just _, Nothing) -> Left "TLS cert specified but key missing" + (Nothing, Just _) -> Left "TLS key specified but cert missing" + _ -> pure () - -- If single cert, ensure both cert and key are present - when (hasSingleCert) $ do - case (tlsCert tlsConf, tlsKey tlsConf) of - (Just _, Nothing) -> Left "TLS cert specified but key missing" - (Nothing, Just _) -> Left "TLS key specified but cert missing" - _ -> return () + when hasSNI $ + case (tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of + (Just _, Nothing) -> Left "SNI default_cert specified but default_key missing" + (Nothing, Just _) -> Left "SNI default_key specified but default_cert missing" + (Nothing, Nothing) -> Left "SNI configuration requires default_cert and default_key" + _ -> pure () - -- If SNI, ensure default cert/key are present - when (hasSNI) $ do - case (tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of - (Just _, Nothing) -> Left "SNI default_cert specified but default_key missing" - (Nothing, Just _) -> Left "SNI default_key specified but default_cert missing" - (Nothing, Nothing) -> Left "SNI configuration requires default_cert and default_key" - _ -> return () - - return () + pure () diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/DDoS/ConnLimit.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/DDoS/ConnLimit.hs index 67290f4e..a5d302ee 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/DDoS/ConnLimit.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/DDoS/ConnLimit.hs @@ -30,6 +30,7 @@ import Control.Concurrent.STM ) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as BS8 +import Data.List (intercalate) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Network.Socket @@ -101,9 +102,4 @@ ipBytesFromSockAddr (SockAddrUnix p) = BS8.pack ("unix:" <> p) v6Bytes :: HostAddress6 -> ByteString v6Bytes ha = let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple ha - in BS8.pack (joinColons (map (`showHex` "") [a, b, c, d, e, f, g, h])) - -joinColons :: [String] -> String -joinColons [] = "" -joinColons [x] = x -joinColons (x : xs) = x <> ":" <> joinColons xs + in BS8.pack (intercalate ":" (map (`showHex` "") [a, b, c, d, e, f, g, h])) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Fingerprint/JA4H.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Fingerprint/JA4H.hs index 1b82af45..45085ba7 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Fingerprint/JA4H.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Fingerprint/JA4H.hs @@ -88,11 +88,6 @@ refererHeaderName = "referer" acceptLanguageHeaderName :: CI ByteString acceptLanguageHeaderName = "accept-language" -lookupCI :: CI ByteString -> [(CI ByteString, ByteString)] -> Maybe ByteString -lookupCI k hs = case filter ((== k) . fst) hs of - ((_, v) : _) -> Just v - _ -> Nothing - acceptLanguagePrefix :: ByteString -> ByteString acceptLanguagePrefix bs = let firstTag = BS.takeWhile (\c -> c /= 0x2c && c /= 0x3b && c /= 0x20) bs @@ -136,7 +131,7 @@ computeJA4H req = filter (\(k, _) -> k /= cookieHeaderName && k /= refererHeaderName) rawHeaders headerCount = padTwo (length filteredHeaders) langPrefix = maybe "0000" acceptLanguagePrefix - $ lookupCI acceptLanguageHeaderName rawHeaders + $ lookup acceptLanguageHeaderName rawHeaders partA = BS.concat [method, version, cookieFlag, refererFlag, headerCount, langPrefix] headerNameList = BS.intercalate "," (map (CI.original . fst) filteredHeaders) headerHash = hashTruncated headerNameList diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/HealthCheck.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/HealthCheck.hs index 9e6b949b..46342f69 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/HealthCheck.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/HealthCheck.hs @@ -1,5 +1,9 @@ -{-# LANGUAGE RecordWildCards #-} +{- +©AngelaMos | 2026 +HealthCheck.hs +-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} module Aenebris.HealthCheck ( HealthCheckConfig(..) @@ -10,10 +14,11 @@ module Aenebris.HealthCheck ) where import Aenebris.Backend +import Aenebris.Connection (httpOkStatusCode, microsPerSecond) import Control.Concurrent (threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM -import Control.Monad (forever) +import Control.Monad (forever, zipWithM_) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) @@ -21,71 +26,67 @@ import Network.HTTP.Client import Network.HTTP.Types.Status (statusCode) import System.Timeout (timeout) --- | Health check configuration +defaultHealthCheckIntervalSeconds :: Int +defaultHealthCheckIntervalSeconds = 10 + +defaultHealthCheckTimeoutSeconds :: Int +defaultHealthCheckTimeoutSeconds = 2 + +defaultHealthCheckMaxFailures :: Int +defaultHealthCheckMaxFailures = 3 + +defaultHealthCheckRecoveryAttempts :: Int +defaultHealthCheckRecoveryAttempts = 2 + +defaultHealthCheckEndpoint :: Text +defaultHealthCheckEndpoint = "/health" + data HealthCheckConfig = HealthCheckConfig - { hcInterval :: Int -- Seconds between checks - , hcTimeout :: Int -- Request timeout (seconds) - , hcEndpoint :: Text -- Health endpoint path (e.g., "/health") - , hcMaxFailures :: Int -- Failures before marking unhealthy - , hcRecoveryAttempts :: Int -- Successes before marking healthy + { hcInterval :: !Int + , hcTimeout :: !Int + , hcEndpoint :: !Text + , hcMaxFailures :: !Int + , hcRecoveryAttempts :: !Int } --- | Default health check configuration defaultHealthCheckConfig :: HealthCheckConfig defaultHealthCheckConfig = HealthCheckConfig - { hcInterval = 10 - , hcTimeout = 2 - , hcEndpoint = "/health" - , hcMaxFailures = 3 - , hcRecoveryAttempts = 2 + { hcInterval = defaultHealthCheckIntervalSeconds + , hcTimeout = defaultHealthCheckTimeoutSeconds + , hcEndpoint = defaultHealthCheckEndpoint + , hcMaxFailures = defaultHealthCheckMaxFailures + , hcRecoveryAttempts = defaultHealthCheckRecoveryAttempts } --- | Start health checker (returns Async handle for stopping) -startHealthChecker :: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO (Async ()) +startHealthChecker + :: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO (Async ()) startHealthChecker manager config backends = - async $ healthCheckLoop manager config backends + async (healthCheckLoop manager config backends) --- | Stop health checker stopHealthChecker :: Async () -> IO () stopHealthChecker = cancel --- | Main health check loop healthCheckLoop :: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO () healthCheckLoop manager config backends = forever $ do - -- Check all backends concurrently results <- mapConcurrently (performHealthCheck manager config) backends - - -- Update backend states based on results atomically $ zipWithM_ (updateBackendState config) backends results + threadDelay (hcInterval config * microsPerSecond) - threadDelay (hcInterval config * 1000000) - --- | Perform HTTP health check on a backend performHealthCheck :: Manager -> HealthCheckConfig -> RuntimeBackend -> IO Bool performHealthCheck manager config backend = do let url = "http://" ++ T.unpack (rbHost backend) ++ T.unpack (hcEndpoint config) - - -- Try to make request with timeout - result <- timeout (hcTimeout config * 1000000) $ do + result <- timeout (hcTimeout config * microsPerSecond) $ do req <- parseRequest url response <- httpLbs req manager - return $ statusCode (responseStatus response) == 200 - - -- Update last check time + pure (statusCode (responseStatus response) == httpOkStatusCode) now <- getCurrentTime atomically $ writeTVar (rbLastHealthCheck backend) (Just now) - - return $ case result of + pure $ case result of Just True -> True - _ -> False + _ -> False --- | Update backend state based on health check result updateBackendState :: HealthCheckConfig -> RuntimeBackend -> Bool -> STM () updateBackendState config backend healthy = if healthy then recordSuccess backend (hcRecoveryAttempts config) else recordFailure backend (hcMaxFailures config) - --- | Helper: zip with monadic action -zipWithM_ :: Monad m => (a -> b -> m c) -> [a] -> [b] -> m () -zipWithM_ f xs ys = sequence_ (zipWith f xs ys) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Honeypot.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Honeypot.hs index c1d90cc7..0e819b4f 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Honeypot.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Honeypot.hs @@ -47,7 +47,7 @@ import qualified Data.Text.Encoding as TE import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime) import Data.Word (Word64) import GHC.Generics (Generic) -import Network.HTTP.Types (Status, mkStatus) +import Network.HTTP.Types (status200, status404) import Network.Wai ( Middleware , Response @@ -219,12 +219,6 @@ trapLabel :: TrapPattern -> ByteString trapLabel (TrapExact e) = e trapLabel (TrapPrefix p) = p <> "*" -status404 :: Status -status404 = mkStatus 404 "Not Found" - -status200 :: Status -status200 = mkStatus 200 "OK" - honeypotMiddleware :: HoneypotConfig -> Maybe IPJail -> Middleware honeypotMiddleware cfg@HoneypotConfig{..} mJail app req respond | hpServeRobotsTxt && requestMethod req == "GET" diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/LoadBalancer.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/LoadBalancer.hs index 48de738a..56b82f8f 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/LoadBalancer.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/LoadBalancer.hs @@ -1,3 +1,7 @@ +{- +©AngelaMos | 2026 +LoadBalancer.hs +-} {-# LANGUAGE RecordWildCards #-} module Aenebris.LoadBalancer @@ -9,142 +13,94 @@ module Aenebris.LoadBalancer import Aenebris.Backend import Control.Concurrent.STM +import Control.Monad (filterM, forM_) import Data.IORef -import Data.List (minimumBy, find) +import Data.List (maximumBy, minimumBy) import Data.Ord (comparing) import qualified Data.Vector as V import Data.Vector (Vector, (!)) --- | Load balancing strategy +initialRoundRobinIndex :: Int +initialRoundRobinIndex = 0 + data LoadBalancerStrategy = RoundRobin | LeastConnections | WeightedRoundRobin deriving (Eq, Show) --- | Load balancer state data LoadBalancer = LoadBalancer - { lbBackends :: Vector RuntimeBackend - , lbStrategy :: LoadBalancerStrategy - , lbRRCounter :: IORef Int -- For round robin + { lbBackends :: !(Vector RuntimeBackend) + , lbStrategy :: !LoadBalancerStrategy + , lbRRCounter :: !(IORef Int) } --- | Create a load balancer for given backends -createLoadBalancer :: LoadBalancerStrategy -> [RuntimeBackend] -> IO LoadBalancer +createLoadBalancer + :: LoadBalancerStrategy -> [RuntimeBackend] -> IO LoadBalancer createLoadBalancer strategy backends = do - counter <- newIORef 0 - return LoadBalancer - { lbBackends = V.fromList backends - , lbStrategy = strategy + counter <- newIORef initialRoundRobinIndex + pure LoadBalancer + { lbBackends = V.fromList backends + , lbStrategy = strategy , lbRRCounter = counter } --- | Select a backend using the configured strategy selectBackend :: LoadBalancer -> IO (Maybe RuntimeBackend) -selectBackend lb = - case lbStrategy lb of - RoundRobin -> selectRoundRobin lb - LeastConnections -> selectLeastConnections lb - WeightedRoundRobin -> selectWeightedRR lb +selectBackend lb = case lbStrategy lb of + RoundRobin -> selectRoundRobin lb + LeastConnections -> selectLeastConnections lb + WeightedRoundRobin -> selectWeightedRR lb --- Round-Robin Implementation (IORef-based, fastest) selectRoundRobin :: LoadBalancer -> IO (Maybe RuntimeBackend) selectRoundRobin LoadBalancer{..} = do let backends = lbBackends - len = V.length backends - + len = V.length backends if len == 0 - then return Nothing + then pure Nothing else do - -- Get next index idx <- atomicModifyIORef' lbRRCounter $ \i -> let next = (i + 1) `mod` len in (next, i) - - -- Find next healthy backend (try all, wrapping around) findHealthyBackend backends idx len --- | Find next healthy backend starting from index -findHealthyBackend :: Vector RuntimeBackend -> Int -> Int -> IO (Maybe RuntimeBackend) -findHealthyBackend backends startIdx totalBackends = - go startIdx totalBackends +findHealthyBackend + :: Vector RuntimeBackend -> Int -> Int -> IO (Maybe RuntimeBackend) +findHealthyBackend backends startIdx totalBackends = go startIdx totalBackends where len = V.length backends - go currentIdx remaining - | remaining <= 0 = return Nothing -- Tried all, none healthy + | remaining <= 0 = pure Nothing | otherwise = do let backend = backends ! currentIdx - healthy <- atomically $ isHealthy backend - + healthy <- atomically (isHealthy backend) if healthy - then return (Just backend) + then pure (Just backend) else go ((currentIdx + 1) `mod` len) (remaining - 1) - --- Least Connections Implementation (STM-based) selectLeastConnections :: LoadBalancer -> IO (Maybe RuntimeBackend) selectLeastConnections LoadBalancer{..} = atomically $ do let backends = V.toList lbBackends - - -- Filter to only healthy backends healthy <- filterM isHealthy backends - case healthy of - [] -> return Nothing - backends' -> do - -- Get connection counts for all healthy backends - counts <- mapM getConnectionCount backends' + [] -> pure Nothing + chosen -> do + counts <- mapM getConnectionCount chosen + let (_, minBackend) = minimumBy (comparing fst) (zip counts chosen) + pure (Just minBackend) - -- Find backend with minimum connections - let (_, minBackend) = minimumBy (comparing fst) (zip counts backends') - - return (Just minBackend) - - --- Smooth Weighted Round-Robin (nginx algorithm, STM-based) selectWeightedRR :: LoadBalancer -> IO (Maybe RuntimeBackend) selectWeightedRR LoadBalancer{..} = atomically $ do let backends = V.toList lbBackends - - -- Filter to only healthy backends healthy <- filterM isHealthy backends - case healthy of - [] -> return Nothing - backends' -> do - -- Step 1: Increase each backend's current weight by its base weight - forM_ backends' $ \rb -> do - currentW <- readTVar (rbCurrentWeight rb) - let newWeight = currentW + rbWeight rb - writeTVar (rbCurrentWeight rb) newWeight - - -- Step 2: Select backend with maximum current weight - weights <- mapM getCurrentWeight backends' - let maxWeight = maximum weights - selectedIdx = find (\i -> weights !! i == maxWeight) [0..length weights - 1] - selected = backends' !! (fromMaybe 0 selectedIdx) - - -- Step 3: Reduce selected backend's current weight by total weight - let totalWeight = sum (map rbWeight backends') - currentW <- readTVar (rbCurrentWeight selected) - writeTVar (rbCurrentWeight selected) (currentW - totalWeight) - - return (Just selected) - --- Helper: STM filter -filterM :: Monad m => (a -> m Bool) -> [a] -> m [a] -filterM _ [] = return [] -filterM p (x:xs) = do - b <- p x - rest <- filterM p xs - return $ if b then x : rest else rest - --- Helper: fromMaybe -fromMaybe :: a -> Maybe a -> a -fromMaybe def Nothing = def -fromMaybe _ (Just x) = x - --- Helper: forM_ -forM_ :: Monad m => [a] -> (a -> m b) -> m () -forM_ xs f = sequence_ (map f xs) + [] -> pure Nothing + chosen -> do + forM_ chosen $ \rb -> + modifyTVar' (rbCurrentWeight rb) (+ rbWeight rb) + tagged <- mapM + (\rb -> (\w -> (w, rb)) <$> readTVar (rbCurrentWeight rb)) + chosen + let (_, picked) = maximumBy (comparing fst) tagged + totalWeight = sum (map rbWeight chosen) + modifyTVar' (rbCurrentWeight picked) (subtract totalWeight) + pure (Just picked) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Features.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Features.hs index 8955505d..d2519a05 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Features.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Features.hs @@ -494,17 +494,17 @@ extractFeatures FeatureContext{..} req = let !headers = requestHeaders req !path = rawPathInfo req !method = requestMethod req - !mAcceptLang = lookupCI acceptLanguageHeader headers - !mUserAgent = lookupCI userAgentHeader headers - !mAcceptEnc = lookupCI acceptEncodingHeader headers - !mReferer = lookupCI refererHeader headers - !mCookie = lookupCI cookieHeader headers - !mSecChUa = lookupCI secChUaHeader headers - !mSecChPlat = lookupCI secChUaPlatformHeader headers - !mSecFetchSite = lookupCI secFetchSiteHeader headers - !mSecFetchMode = lookupCI secFetchModeHeader headers - !mSecFetchDest = lookupCI secFetchDestHeader headers - !mAccept = lookupCI acceptHeader headers + !mAcceptLang = lookup acceptLanguageHeader headers + !mUserAgent = lookup userAgentHeader headers + !mAcceptEnc = lookup acceptEncodingHeader headers + !mReferer = lookup refererHeader headers + !mCookie = lookup cookieHeader headers + !mSecChUa = lookup secChUaHeader headers + !mSecChPlat = lookup secChUaPlatformHeader headers + !mSecFetchSite = lookup secFetchSiteHeader headers + !mSecFetchMode = lookup secFetchModeHeader headers + !mSecFetchDest = lookup secFetchDestHeader headers + !mAccept = lookup acceptHeader headers !uaBytes = fromMaybe BS.empty mUserAgent !uaLen = BS.length uaBytes !depth = pathDepth path @@ -552,8 +552,3 @@ extractFeatures FeatureContext{..} req = , fHeaderOrderCanonical = boolToDouble (headerOrderIsCanonicalBrowser headers) } - -lookupCI :: CI ByteString -> [(CI ByteString, ByteString)] -> Maybe ByteString -lookupCI k hs = case filter ((== k) . fst) hs of - ((_, v) : _) -> Just v - _ -> Nothing diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Redirect.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Redirect.hs index c0a5e955..a08b8aaf 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Redirect.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Redirect.hs @@ -1,3 +1,7 @@ +{- +©AngelaMos | 2026 +Redirect.hs +-} {-# LANGUAGE OverloadedStrings #-} module Aenebris.Middleware.Redirect @@ -7,38 +11,44 @@ module Aenebris.Middleware.Redirect import qualified Data.ByteString.Char8 as BS import Data.Maybe (fromMaybe) -import Network.HTTP.Types (status301, hLocation) -import Network.Wai (Middleware, responseLBS, requestHeaderHost, rawPathInfo, rawQueryString, isSecure) +import Network.HTTP.Types (hLocation, status301) +import Network.Wai + ( Middleware + , isSecure + , rawPathInfo + , rawQueryString + , requestHeaderHost + , responseLBS + ) + +defaultHostFallback :: BS.ByteString +defaultHostFallback = "localhost" + +httpsScheme :: BS.ByteString +httpsScheme = "https://" + +standardHttpsPort :: Int +standardHttpsPort = 443 + +redirectBody :: BS.ByteString +redirectBody = "Redirecting to HTTPS" --- | Redirect HTTP requests to HTTPS (assumes HTTPS is on port 443) httpsRedirect :: Middleware httpsRedirect = httpsRedirectWithPort Nothing --- | Redirect HTTP requests to HTTPS with optional custom port --- If port is Nothing, assumes 443 (standard HTTPS port, no port in URL) --- If port is Just n, includes :n in the redirect URL httpsRedirectWithPort :: Maybe Int -> Middleware httpsRedirectWithPort httpsPort app req respond - | isSecure req = app req respond -- Already HTTPS, pass through + | isSecure req = app req respond | otherwise = do - -- Get host from Host header - let hostHeader = fromMaybe "localhost" $ requestHeaderHost req - - -- Build HTTPS URL with optional port + let hostHeader = fromMaybe defaultHostFallback (requestHeaderHost req) host = case httpsPort of - Nothing -> hostHeader -- Standard 443, don't include port - Just 443 -> hostHeader -- Standard 443, don't include port + Nothing -> hostHeader + Just port | port == standardHttpsPort -> hostHeader Just port -> hostHeader <> ":" <> BS.pack (show port) - - -- Get path and query string (already encoded in rawPathInfo) - path = rawPathInfo req - query = rawQueryString req - - -- Build full redirect URL - redirectUrl = "https://" <> host <> path <> query - - -- Send 301 permanent redirect + path = rawPathInfo req + query = rawQueryString req + redirectUrl = httpsScheme <> host <> path <> query respond $ responseLBS status301 [(hLocation, redirectUrl)] - "Redirecting to HTTPS" + (BS.fromStrict redirectBody) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Security.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Security.hs index 9706b41d..d49da10d 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Security.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Middleware/Security.hs @@ -1,3 +1,7 @@ +{- +©AngelaMos | 2026 +Security.hs +-} {-# LANGUAGE OverloadedStrings #-} module Aenebris.Middleware.Security @@ -11,125 +15,101 @@ module Aenebris.Middleware.Security import Data.ByteString (ByteString) import qualified Data.CaseInsensitive as CI +import Data.Maybe (catMaybes) import Network.HTTP.Types (Header, ResponseHeaders) import Network.Wai (Middleware, mapResponseHeaders) --- | Security level presets data SecurityLevel - = Testing -- Short HSTS, permissive CSP, for development - | Production -- Balanced security for production - | Strict -- Maximum security, strict CSP, HSTS preload + = Testing + | Production + | Strict deriving (Show, Eq) --- | Security configuration data SecurityConfig = SecurityConfig - { scHSTS :: Maybe ByteString -- Strict-Transport-Security header - , scCSP :: Maybe ByteString -- Content-Security-Policy header - , scFrameOptions :: Maybe ByteString -- X-Frame-Options header - , scContentTypeOptions :: Bool -- X-Content-Type-Options: nosniff - , scReferrerPolicy :: Maybe ByteString -- Referrer-Policy header - , scPermissionsPolicy :: Maybe ByteString -- Permissions-Policy header - , scXSSProtection :: Maybe ByteString -- X-XSS-Protection (legacy, but some crawlers check) - , scExpectCT :: Maybe ByteString -- Expect-CT (transitional) - , scServerHeader :: Maybe ByteString -- Server header (hide or customize) - , scRemovePoweredBy :: Bool -- Remove X-Powered-By headers + { scHSTS :: !(Maybe ByteString) + , scCSP :: !(Maybe ByteString) + , scFrameOptions :: !(Maybe ByteString) + , scContentTypeOptions :: !Bool + , scReferrerPolicy :: !(Maybe ByteString) + , scPermissionsPolicy :: !(Maybe ByteString) + , scXSSProtection :: !(Maybe ByteString) + , scExpectCT :: !(Maybe ByteString) + , scServerHeader :: !(Maybe ByteString) + , scRemovePoweredBy :: !Bool } deriving (Show, Eq) --- | Testing/development security configuration --- Use short HSTS for easy testing, permissive CSP testingSecurityConfig :: SecurityConfig testingSecurityConfig = SecurityConfig - { scHSTS = Just "max-age=300" -- 5 minutes for testing - , scCSP = Just "default-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" - , scFrameOptions = Just "SAMEORIGIN" - , scContentTypeOptions = True - , scReferrerPolicy = Just "strict-origin-when-cross-origin" - , scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=()" - , scXSSProtection = Just "1; mode=block" - , scExpectCT = Nothing - , scServerHeader = Just "Aenebris/0.1.0" - , scRemovePoweredBy = True + { scHSTS = Just "max-age=300" + , scCSP = Just "default-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" + , scFrameOptions = Just "SAMEORIGIN" + , scContentTypeOptions = True + , scReferrerPolicy = Just "strict-origin-when-cross-origin" + , scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=()" + , scXSSProtection = Just "1; mode=block" + , scExpectCT = Nothing + , scServerHeader = Just "Aenebris/0.1.0" + , scRemovePoweredBy = True } --- | Production security configuration --- Balanced security, 1-month HSTS defaultSecurityConfig :: SecurityConfig defaultSecurityConfig = SecurityConfig - { scHSTS = Just "max-age=2592000; includeSubDomains" -- 30 days - , scCSP = Just "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'" - , scFrameOptions = Just "DENY" - , scContentTypeOptions = True - , scReferrerPolicy = Just "strict-origin-when-cross-origin" - , scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" - , scXSSProtection = Just "1; mode=block" - , scExpectCT = Just "max-age=86400, enforce" - , scServerHeader = Just "Aenebris" -- Don't reveal version in production - , scRemovePoweredBy = True + { scHSTS = Just "max-age=2592000; includeSubDomains" + , scCSP = Just "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'" + , scFrameOptions = Just "DENY" + , scContentTypeOptions = True + , scReferrerPolicy = Just "strict-origin-when-cross-origin" + , scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" + , scXSSProtection = Just "1; mode=block" + , scExpectCT = Just "max-age=86400, enforce" + , scServerHeader = Just "Aenebris" + , scRemovePoweredBy = True } --- | Strict security configuration for maximum protection --- 2-year HSTS with preload, very restrictive CSP strictSecurityConfig :: SecurityConfig strictSecurityConfig = SecurityConfig - { scHSTS = Just "max-age=63072000; includeSubDomains; preload" -- 2 years + preload - , scCSP = Just "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" - , scFrameOptions = Just "DENY" - , scContentTypeOptions = True - , scReferrerPolicy = Just "no-referrer" -- Strictest, no referrer leakage - , scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), bluetooth=(), display-capture=(), document-domain=()" - , scXSSProtection = Just "1; mode=block" - , scExpectCT = Just "max-age=86400, enforce" - , scServerHeader = Nothing -- Hide completely - , scRemovePoweredBy = True + { scHSTS = Just "max-age=63072000; includeSubDomains; preload" + , scCSP = Just "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" + , scFrameOptions = Just "DENY" + , scContentTypeOptions = True + , scReferrerPolicy = Just "no-referrer" + , scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), bluetooth=(), display-capture=(), document-domain=()" + , scXSSProtection = Just "1; mode=block" + , scExpectCT = Just "max-age=86400, enforce" + , scServerHeader = Nothing + , scRemovePoweredBy = True } --- | Middleware that adds security headers to all responses addSecurityHeaders :: SecurityConfig -> Middleware addSecurityHeaders config app req respond = app req $ \res -> respond $ mapResponseHeaders (addHeaders config) res --- | Add security headers to response headers addHeaders :: SecurityConfig -> ResponseHeaders -> ResponseHeaders addHeaders config headers = - let - -- Remove headers we want to control - cleaned = if scRemovePoweredBy config - then filter (not . isPoweredBy) headers - else headers - - -- Build new security headers - newHeaders = catMaybes - [ fmap (\v -> ("Strict-Transport-Security", v)) (scHSTS config) - , fmap (\v -> ("Content-Security-Policy", v)) (scCSP config) - , fmap (\v -> ("X-Frame-Options", v)) (scFrameOptions config) - , if scContentTypeOptions config - then Just ("X-Content-Type-Options", "nosniff") - else Nothing - , fmap (\v -> ("Referrer-Policy", v)) (scReferrerPolicy config) - , fmap (\v -> ("Permissions-Policy", v)) (scPermissionsPolicy config) - , fmap (\v -> ("X-XSS-Protection", v)) (scXSSProtection config) - , fmap (\v -> ("Expect-CT", v)) (scExpectCT config) - ] - - -- Handle Server header specially - serverHeader = case scServerHeader config of - Just v -> [("Server", v)] - Nothing -> [] -- Remove Server header completely - - -- Remove existing Server header if we're replacing it - withoutServer = filter (not . isServerHeader) cleaned - + let cleaned = if scRemovePoweredBy config + then filter (not . isPoweredBy) headers + else headers + newHeaders = catMaybes + [ fmap (\v -> ("Strict-Transport-Security", v)) (scHSTS config) + , fmap (\v -> ("Content-Security-Policy", v)) (scCSP config) + , fmap (\v -> ("X-Frame-Options", v)) (scFrameOptions config) + , if scContentTypeOptions config + then Just ("X-Content-Type-Options", "nosniff") + else Nothing + , fmap (\v -> ("Referrer-Policy", v)) (scReferrerPolicy config) + , fmap (\v -> ("Permissions-Policy", v)) (scPermissionsPolicy config) + , fmap (\v -> ("X-XSS-Protection", v)) (scXSSProtection config) + , fmap (\v -> ("Expect-CT", v)) (scExpectCT config) + ] + serverHeader = case scServerHeader config of + Just v -> [("Server", v)] + Nothing -> [] + withoutServer = filter (not . isServerHeader) cleaned in withoutServer ++ newHeaders ++ serverHeader --- | Check if header is X-Powered-By isPoweredBy :: Header -> Bool isPoweredBy (name, _) = CI.mk name == CI.mk "X-Powered-By" --- | Check if header is Server isServerHeader :: Header -> Bool isServerHeader (name, _) = CI.mk name == CI.mk "Server" - --- | catMaybes implementation (since we're not importing Data.Maybe) -catMaybes :: [Maybe a] -> [a] -catMaybes = foldr (\mx xs -> case mx of Just x -> x:xs; Nothing -> xs) [] diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Proxy.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Proxy.hs index c8e3aea6..5da3745a 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Proxy.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/Proxy.hs @@ -1,6 +1,10 @@ +{- +©AngelaMos | 2026 +Proxy.hs +-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} module Aenebris.Proxy ( ProxyState(..) @@ -25,7 +29,12 @@ import Aenebris.TLS import Aenebris.Tunnel import Aenebris.Middleware.Security import Aenebris.Middleware.Redirect -import Aenebris.RateLimit (RateLimiter, createRateLimiter, parseRateSpec, rateLimitMiddleware) +import Aenebris.RateLimit + ( RateLimiter + , createRateLimiter + , parseRateSpec + , rateLimitMiddleware + ) import Aenebris.DDoS.EarlyData (earlyDataGuard) import Aenebris.DDoS.MemoryShed ( MemoryShed @@ -72,7 +81,13 @@ import Aenebris.Geo ) import Control.Concurrent.STM (TVar, newTVarIO) import Control.Concurrent.Async (Async, async, waitAnyCancel) -import Control.Exception (try, SomeException) +import Control.Exception (SomeException, try) +import Control.Monad (unless, zipWithM) +import Data.ByteString (ByteString) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BS8 +import Data.ByteString.Builder (byteString) +import qualified Data.ByteString.Lazy as LBS import Data.Function ((&)) import Data.List (sortBy) import Data.Map.Strict (Map) @@ -82,12 +97,16 @@ import Data.Ord (comparing) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE -import Network.HTTP.Client (Manager, withResponse, parseRequest, RequestBody(..), brRead) +import Network.HTTP.Client + ( Manager + , RequestBody(..) + , brRead + , parseRequest + , withResponse + ) import qualified Network.HTTP.Client as HTTP import Network.HTTP.Types import Network.Wai -import Data.ByteString.Builder (byteString) -import Control.Monad (unless) import Network.Wai.Handler.Warp ( Settings , defaultSettings @@ -102,33 +121,77 @@ import Network.Wai.Handler.WarpTLS (runTLS) import System.Exit (exitFailure) import System.IO (hPutStrLn, stderr) import System.Timeout (timeout) -import Data.ByteString (ByteString) -import qualified Data.ByteString as BS -import qualified Data.ByteString.Char8 as BS8 -import qualified Data.ByteString.Lazy as LBS --- | Proxy runtime state +memoryShedPollIntervalMicros :: Int +memoryShedPollIntervalMicros = microsPerSecond + +contentTypePlain :: ByteString +contentTypePlain = "text/plain" + +bodyNotFound :: LBS.ByteString +bodyNotFound = "Not Found: No route configured for this host/path" + +bodyUpstreamMisconfigured :: LBS.ByteString +bodyUpstreamMisconfigured = "Internal Server Error: Upstream configuration error" + +bodyNoHealthyBackends :: LBS.ByteString +bodyNoHealthyBackends = "Service Unavailable: No healthy backends available" + +bodyBadGateway :: LBS.ByteString +bodyBadGateway = "Bad Gateway: Could not connect to backend server" + +bodyGatewayTimeout :: LBS.ByteString +bodyGatewayTimeout = "504 Gateway Timeout: upstream did not respond in time" + +bodyWebSocketUpgradeFailed :: LBS.ByteString +bodyWebSocketUpgradeFailed = "WebSocket upgrade failed" + +hopByHopRequestHeaders :: [HeaderName] +hopByHopRequestHeaders = + [ "Connection" + , "Keep-Alive" + , "Proxy-Authenticate" + , "Proxy-Authorization" + , "TE" + , "Trailers" + , "Transfer-Encoding" + , "Upgrade" + ] + +hopByHopResponseHeaders :: [HeaderName] +hopByHopResponseHeaders = + [ "Transfer-Encoding" + , "Connection" + , "Keep-Alive" + ] + +eventStreamContentType :: ByteString +eventStreamContentType = "text/event-stream" + +chunkedEncoding :: ByteString +chunkedEncoding = "chunked" + +httpScheme :: String +httpScheme = "http://" + data ProxyState = ProxyState - { psConfig :: Config - , psLoadBalancers :: Map Text LoadBalancer -- upstream name -> load balancer - , psHealthCheckers :: [Async ()] - , psManager :: Manager - , psRateLimiter :: Maybe RateLimiter - , psMemoryShed :: Maybe MemoryShed - , psIPJail :: Maybe IPJail - , psConnLimiter :: Maybe ConnLimiter - , psWafRuleSet :: TVar RuleSet - , psGeo :: Maybe Geo + { psConfig :: !Config + , psLoadBalancers :: !(Map Text LoadBalancer) + , psHealthCheckers :: ![Async ()] + , psManager :: !Manager + , psRateLimiter :: !(Maybe RateLimiter) + , psMemoryShed :: !(Maybe MemoryShed) + , psIPJail :: !(Maybe IPJail) + , psConnLimiter :: !(Maybe ConnLimiter) + , psWafRuleSet :: !(TVar RuleSet) + , psGeo :: !(Maybe Geo) } --- | Initialize proxy state from config initProxyState :: Config -> Manager -> IO ProxyState initProxyState config manager = do - -- Create load balancers for each upstream lbs <- mapM createUpstreamLoadBalancer (configUpstreams config) - let lbMap = Map.fromList (zip (map upstreamName $ configUpstreams config) lbs) + let lbMap = Map.fromList (zip (map upstreamName (configUpstreams config)) lbs) - -- Start health checkers for all upstreams checkers <- mapM startUpstreamHealthChecker (configUpstreams config) rateLimiter <- case configRateLimit config >>= parseRateSpec of @@ -142,8 +205,9 @@ initProxyState config manager = do ms <- newMemoryShed let cfg = MemoryShedConfig { mscHeapBudgetBytes = fromInteger budgetBytes - , mscHighWaterFraction = fromMaybe defaultHighWaterFraction (ddos >>= ddosMemoryShedHighWater) - , mscPollIntervalMicros = 1000000 + , mscHighWaterFraction = fromMaybe defaultHighWaterFraction + (ddos >>= ddosMemoryShedHighWater) + , mscPollIntervalMicros = memoryShedPollIntervalMicros } _ <- startMemoryShedPoller cfg ms pure (Just ms) @@ -170,57 +234,45 @@ initProxyState config manager = do Nothing -> pure Nothing return ProxyState - { psConfig = config - , psLoadBalancers = lbMap + { psConfig = config + , psLoadBalancers = lbMap , psHealthCheckers = checkers - , psManager = manager - , psRateLimiter = rateLimiter - , psMemoryShed = memShed - , psIPJail = ipJail - , psConnLimiter = connLimiter - , psWafRuleSet = wafVar - , psGeo = geoHandle + , psManager = manager + , psRateLimiter = rateLimiter + , psMemoryShed = memShed + , psIPJail = ipJail + , psConnLimiter = connLimiter + , psWafRuleSet = wafVar + , psGeo = geoHandle } where - -- Create load balancer for an upstream createUpstreamLoadBalancer :: Upstream -> IO LoadBalancer createUpstreamLoadBalancer upstream = do - -- Convert Config Servers to RuntimeBackends backends <- zipWithM createRuntimeBackend [0..] (upstreamServers upstream) - - -- Determine strategy (for now, use weighted if weights differ, else round-robin) let weights = map serverWeight (upstreamServers upstream) strategy = case weights of - [] -> RoundRobin -- No backends, shouldn't happen but be safe - (w:ws) -> if all (== w) ws - then RoundRobin - else WeightedRoundRobin - + [] -> RoundRobin + (w:ws) + | all (== w) ws -> RoundRobin + | otherwise -> WeightedRoundRobin createLoadBalancer strategy backends - -- Start health checker for an upstream startUpstreamHealthChecker :: Upstream -> IO (Async ()) startUpstreamHealthChecker upstream = do backends <- zipWithM createRuntimeBackend [0..] (upstreamServers upstream) - - -- Use health check config from upstream, or defaults let hcConfig = case upstreamHealthCheck upstream of Just hc -> defaultHealthCheckConfig - { hcInterval = 10 -- TODO: parse interval from config - , hcEndpoint = healthCheckPath hc + { hcEndpoint = healthCheckPath hc } Nothing -> defaultHealthCheckConfig - startHealthChecker manager hcConfig backends --- | Start the proxy server with given configuration --- Supports multiple ports with HTTP and HTTPS (including SNI) startProxy :: ProxyState -> IO () startProxy ProxyState{..} = do - putStrLn $ "Starting Ᾰenebris reverse proxy" - putStrLn $ "Loaded " ++ show (length $ configUpstreams psConfig) ++ " upstream(s)" - putStrLn $ "Loaded " ++ show (length $ configRoutes psConfig) ++ " route(s)" - putStrLn $ "Health checking enabled for all upstreams" + putStrLn "Starting Aenebris reverse proxy" + putStrLn $ "Loaded " ++ show (length (configUpstreams psConfig)) ++ " upstream(s)" + putStrLn $ "Loaded " ++ show (length (configRoutes psConfig)) ++ " route(s)" + putStrLn "Health checking enabled for all upstreams" case configListen psConfig of [] -> do @@ -228,28 +280,37 @@ startProxy ProxyState{..} = do exitFailure listenConfigs -> do case psRateLimiter of - Just _ -> putStrLn "Rate limiting enabled" + Just _ -> putStrLn "Rate limiting enabled" Nothing -> pure () putStrLn "WAF enabled (Phase 1: paranoia level 2, default rule pack)" case buildHoneypotConfig (configHoneypot psConfig) of - Just hp -> putStrLn $ "Honeypot enabled (" ++ show (length (hpPatterns hp)) - ++ " trap patterns, action=" ++ show (hpAction hp) ++ ")" + Just hp -> putStrLn $ + "Honeypot enabled (" + ++ show (length (hpPatterns hp)) + ++ " trap patterns, action=" + ++ show (hpAction hp) + ++ ")" Nothing -> pure () case psGeo of Just g -> let gc = geoConfig g - parts = [ "country_db=" ++ maybe "off" (const "on") (gcCountryDb gc) - , "asn_db=" ++ maybe "off" (const "on") (gcAsnDb gc) - , "blocked=" ++ show (length (gcBlockedCountries gc)) - , "flagged_asns=" ++ show (length (gcFlaggedAsns gc)) - ] + parts = + [ "country_db=" ++ maybe "off" (const "on") (gcCountryDb gc) + , "asn_db=" ++ maybe "off" (const "on") (gcAsnDb gc) + , "blocked=" ++ show (length (gcBlockedCountries gc)) + , "flagged_asns=" ++ show (length (gcFlaggedAsns gc)) + ] in putStrLn $ "Geo/ASN enabled (" ++ unwords parts ++ ")" Nothing -> pure () - servers <- mapM (launchServer psConfig psLoadBalancers psManager psRateLimiter psMemoryShed psIPJail psConnLimiter psWafRuleSet psGeo) listenConfigs + + servers <- mapM + (launchServer psConfig psLoadBalancers psManager + psRateLimiter psMemoryShed psIPJail + psConnLimiter psWafRuleSet psGeo) + listenConfigs _ <- waitAnyCancel servers - putStrLn "All servers stopped" launchServer @@ -264,265 +325,253 @@ launchServer -> Maybe Geo -> ListenConfig -> IO (Async ()) -launchServer config loadBalancers manager mRateLimiter mMemShed mIPJail mConnLim wafVar mGeo listenConfig = async $ do - let port = listenPort listenConfig - shouldRedirect = fromMaybe False (listenRedirectHTTPS listenConfig) - ddosCfg = fromMaybe defaultDDoSConfig (configDDoS config) +launchServer config loadBalancers manager mRateLimiter mMemShed mIPJail mConnLim wafVar mGeo listenConfig = + async $ do + let port = listenPort listenConfig + shouldRedirect = fromMaybe False (listenRedirectHTTPS listenConfig) + ddosCfg = fromMaybe defaultDDoSConfig (configDDoS config) - baseApp = proxyApp config loadBalancers manager + baseApp = proxyApp config loadBalancers manager + fingerprintedApp = ja4hMiddleware baseApp + wafApp = wafMiddleware wafVar fingerprintedApp + securedApp = addSecurityHeaders defaultSecurityConfig wafApp - fingerprintedApp = ja4hMiddleware baseApp + earlyDataApp = if ddosEarlyDataReject ddosCfg + then earlyDataGuard securedApp + else securedApp - wafApp = wafMiddleware wafVar fingerprintedApp + mHoneypotCfg = buildHoneypotConfig (configHoneypot config) + honeypotApp = case mHoneypotCfg of + Just hp -> honeypotMiddleware hp mIPJail earlyDataApp + Nothing -> earlyDataApp - securedApp = addSecurityHeaders defaultSecurityConfig wafApp + geoApp = case mGeo of + Just g -> geoMiddleware g mIPJail honeypotApp + Nothing -> honeypotApp - earlyDataApp = if ddosEarlyDataReject ddosCfg - then earlyDataGuard securedApp - else securedApp + jailedApp = case mIPJail of + Just j -> ipJailMiddleware j geoApp + Nothing -> geoApp - mHoneypotCfg = buildHoneypotConfig (configHoneypot config) + shedApp = case mMemShed of + Just ms -> memoryShedMiddleware ms jailedApp + Nothing -> jailedApp - honeypotApp = case mHoneypotCfg of - Just hp -> honeypotMiddleware hp mIPJail earlyDataApp - Nothing -> earlyDataApp + limitedApp = case mRateLimiter of + Just rl -> rateLimitMiddleware rl shedApp + Nothing -> shedApp - geoApp = case mGeo of - Just g -> geoMiddleware g mIPJail honeypotApp - Nothing -> honeypotApp + warpSettings = applyDDoSSettings ddosCfg mConnLim + (defaultSettings & setPort port) - jailedApp = case mIPJail of - Just j -> ipJailMiddleware j geoApp - Nothing -> geoApp + case listenTLS listenConfig of + Nothing -> do + let app = if shouldRedirect + then httpsRedirect limitedApp + else limitedApp + putStrLn $ "* HTTP server listening on :" ++ show port + if shouldRedirect + then putStrLn " Redirecting all traffic to HTTPS" + else pure () + runSettings warpSettings app - shedApp = case mMemShed of - Just ms -> memoryShedMiddleware ms jailedApp - Nothing -> jailedApp - - limitedApp = case mRateLimiter of - Just rl -> rateLimitMiddleware rl shedApp - Nothing -> shedApp - - warpSettings = applyDDoSSettings ddosCfg mConnLim (defaultSettings & setPort port) - - case listenTLS listenConfig of - Nothing -> do - let app = if shouldRedirect - then httpsRedirect limitedApp - else limitedApp - - putStrLn $ "✓ HTTP server listening on :" ++ show port - if shouldRedirect - then putStrLn $ " └─ Redirecting all traffic to HTTPS" - else return () - - runSettings warpSettings app - - Just tlsConfig -> do - let isSNI = case tlsSNI tlsConfig of - Just domains -> not (null domains) - Nothing -> False - - if isSNI - then launchHTTPSWithSNI port tlsConfig limitedApp - else launchHTTPS port tlsConfig limitedApp + Just tlsConfig -> do + let isSNI = case tlsSNI tlsConfig of + Just domains -> not (null domains) + Nothing -> False + if isSNI + then launchHTTPSWithSNI port tlsConfig limitedApp + else launchHTTPS port tlsConfig limitedApp applyDDoSSettings :: DDoSConfig -> Maybe ConnLimiter -> Settings -> Settings applyDDoSSettings ddos mConnLim s0 = - let s1 = case ddosMaxHeaderBytes ddos of - Just n -> setMaxTotalHeaderLength n s1Inner - Nothing -> s1Inner - s1Inner = case ddosSlowlorisSeconds ddos of - Just n -> setTimeout n s0 + let s1Inner = case ddosSlowlorisSeconds ddos of + Just n -> setTimeout n s0 Nothing -> s0 + s1 = case ddosMaxHeaderBytes ddos of + Just n -> setMaxTotalHeaderLength n s1Inner + Nothing -> s1Inner s2 = case mConnLim of - Just cl -> setOnClose (connLimitOnClose cl) (setOnOpen (connLimitOnOpen cl) s1) + Just cl -> setOnClose (connLimitOnClose cl) + (setOnOpen (connLimitOnOpen cl) s1) Nothing -> s1 in s2 --- | Launch HTTPS server with single certificate launchHTTPS :: Int -> TLSConfig -> Application -> IO () -launchHTTPS port tlsConfig app = do +launchHTTPS port tlsConfig app = case (tlsCert tlsConfig, tlsKey tlsConfig) of (Just certFile, Just keyFile) -> do - -- Load TLS settings tlsResult <- createTLSSettings certFile keyFile case tlsResult of Left err -> do hPutStrLn stderr "ERROR: Failed to load TLS certificate" hPutStrLn stderr $ " " ++ show err exitFailure - Right tlsSettings -> do let warpSettings = defaultSettings & setPort port - putStrLn $ "✓ HTTPS server listening on :" ++ show port - putStrLn $ " ├─ Certificate: " ++ certFile - putStrLn $ " ├─ TLS 1.2 + TLS 1.3 enabled" - putStrLn $ " ├─ HTTP/2 enabled (ALPN)" - putStrLn $ " └─ Strong cipher suites enforced" + putStrLn $ "* HTTPS server listening on :" ++ show port + putStrLn $ " Certificate: " ++ certFile + putStrLn " TLS 1.2 + TLS 1.3 enabled" + putStrLn " HTTP/2 enabled (ALPN)" + putStrLn " Strong cipher suites enforced" runTLS tlsSettings warpSettings app - _ -> do hPutStrLn stderr "ERROR: TLS configuration requires both cert and key" exitFailure --- | Launch HTTPS server with SNI support (multiple certificates) launchHTTPSWithSNI :: Int -> TLSConfig -> Application -> IO () -launchHTTPSWithSNI port tlsConfig app = do +launchHTTPSWithSNI port tlsConfig app = case (tlsSNI tlsConfig, tlsDefaultCert tlsConfig, tlsDefaultKey tlsConfig) of (Just sniDomains, Just defaultCert, Just defaultKey) -> do - -- Convert SNIDomain list to the format expected by createSNISettings let domainList = [(sniDomain d, sniCert d, sniKey d) | d <- sniDomains] - - -- Load SNI TLS settings tlsResult <- createSNISettings domainList defaultCert defaultKey case tlsResult of Left err -> do hPutStrLn stderr "ERROR: Failed to load SNI certificates" hPutStrLn stderr $ " " ++ show err exitFailure - Right tlsSettings -> do let warpSettings = defaultSettings & setPort port - putStrLn $ "✓ HTTPS server with SNI listening on :" ++ show port - putStrLn $ " ├─ SNI domains: " ++ show (length sniDomains) ++ " configured" - mapM_ (\d -> putStrLn $ " │ • " ++ T.unpack (sniDomain d) ++ " -> " ++ sniCert d) sniDomains - putStrLn $ " ├─ Default certificate: " ++ defaultCert - putStrLn $ " ├─ TLS 1.2 + TLS 1.3 enabled" - putStrLn $ " ├─ HTTP/2 enabled (ALPN)" - putStrLn $ " └─ Strong cipher suites enforced" + putStrLn $ "* HTTPS server with SNI listening on :" ++ show port + putStrLn $ " SNI domains: " ++ show (length sniDomains) ++ " configured" + mapM_ + (\d -> putStrLn $ + " " ++ T.unpack (sniDomain d) ++ " -> " ++ sniCert d) + sniDomains + putStrLn $ " Default certificate: " ++ defaultCert + putStrLn " TLS 1.2 + TLS 1.3 enabled" + putStrLn " HTTP/2 enabled (ALPN)" + putStrLn " Strong cipher suites enforced" runTLS tlsSettings warpSettings app - _ -> do - hPutStrLn stderr - "ERROR: SNI requires sni, default_cert, and default_key" + hPutStrLn stderr "ERROR: SNI requires sni, default_cert, and default_key" exitFailure --- | Main proxy application (WAI) proxyApp :: Config -> Map Text LoadBalancer -> Manager -> Application proxyApp config loadBalancers manager req respond = do - logRequest req - - let hostHeader = lookup "Host" (requestHeaders req) + let hostHeader = lookup "Host" (requestHeaders req) requestPath = rawPathInfo req - headers = requestHeaders req - connType = detectConnectionType headers + headers = requestHeaders req + connType = detectConnectionType headers case selectRoute config hostHeader requestPath of Nothing -> do - hPutStrLn stderr $ "ERROR: No route found for request" + hPutStrLn stderr "ERROR: No route found for request" respond $ responseLBS status404 - [("Content-Type", "text/plain")] - "Not Found: No route configured for this host/path" + [(hContentType, contentTypePlain)] + bodyNotFound - Just (upstreamName, _pathRoute) -> do + Just (upstreamName, _pathRoute) -> case Map.lookup upstreamName loadBalancers of Nothing -> do - hPutStrLn stderr $ "ERROR: Load balancer not found: " ++ T.unpack upstreamName + hPutStrLn stderr $ + "ERROR: Load balancer not found: " ++ T.unpack upstreamName respond $ responseLBS status500 - [("Content-Type", "text/plain")] - "Internal Server Error: Upstream configuration error" + [(hContentType, contentTypePlain)] + bodyUpstreamMisconfigured Just loadBalancer -> do mBackend <- selectBackend loadBalancer - case mBackend of Nothing -> do - hPutStrLn stderr $ "ERROR: No healthy backends available" + hPutStrLn stderr "ERROR: No healthy backends available" respond $ responseLBS status503 - [("Content-Type", "text/plain")] - "Service Unavailable: No healthy backends available" + [(hContentType, contentTypePlain)] + bodyNoHealthyBackends - Just backend -> do - case connType of - WebSocket -> do - hPutStrLn stderr $ "[WS] WebSocket upgrade detected" - handleWebSocketUpgrade req respond backend + Just backend -> case connType of + WebSocket -> do + hPutStrLn stderr "[WS] WebSocket upgrade detected" + handleWebSocketUpgrade req respond backend + _ -> + forwardRegular manager backend req respond - RegularHttp -> do - result <- try $ trackConnection backend $ - forwardRequest manager req (rbHost backend) respond +forwardRegular + :: Manager + -> RuntimeBackend + -> Request + -> (Response -> IO ResponseReceived) + -> IO ResponseReceived +forwardRegular manager backend req respond = do + result <- try $ trackConnection backend $ + forwardRequest manager req (rbHost backend) respond + case result of + Left (err :: SomeException) -> do + hPutStrLn stderr $ "ERROR: " ++ show err + respond $ responseLBS + status502 + [(hContentType, contentTypePlain)] + bodyBadGateway + Right responseReceived -> + pure responseReceived - case result of - Left (err :: SomeException) -> do - hPutStrLn stderr $ "ERROR: " ++ show err - respond $ responseLBS - status502 - [("Content-Type", "text/plain")] - "Bad Gateway: Could not connect to backend server" - - Right responseReceived -> - return responseReceived - - _ -> do - result <- try $ trackConnection backend $ - forwardRequest manager req (rbHost backend) respond - - case result of - Left (err :: SomeException) -> do - hPutStrLn stderr $ "ERROR: " ++ show err - respond $ responseLBS - status502 - [("Content-Type", "text/plain")] - "Bad Gateway: Could not connect to backend server" - - Right responseReceived -> - return responseReceived - -handleWebSocketUpgrade :: Request -> (Response -> IO ResponseReceived) -> RuntimeBackend -> IO ResponseReceived +handleWebSocketUpgrade + :: Request + -> (Response -> IO ResponseReceived) + -> RuntimeBackend + -> IO ResponseReceived handleWebSocketUpgrade req respond backend = do let backendHost = rbHost backend backupResponse = responseLBS status502 - [("Content-Type", "text/plain")] - "WebSocket upgrade failed" - + [(hContentType, contentTypePlain)] + bodyWebSocketUpgradeFailed respond $ responseRaw (wsHandler req backendHost) backupResponse -wsHandler :: Request -> Text -> IO ByteString -> (ByteString -> IO ()) -> IO () +wsHandler + :: Request + -> Text + -> IO ByteString + -> (ByteString -> IO ()) + -> IO () wsHandler req backendHost recv send = do hPutStrLn stderr $ "[WS] Starting WebSocket tunnel to " ++ T.unpack backendHost tunnelWebSocket req backendHost send recv --- | Select a route based on Host header and path -selectRoute :: Config -> Maybe BS.ByteString -> BS.ByteString -> Maybe (Text, PathRoute) -selectRoute config hostHeader requestPath = - case hostHeader of - Nothing -> Nothing -- No Host header, can't route - Just host -> do - -- Find route matching this host - let hostText = TE.decodeUtf8 host - matchingRoutes = filter (\r -> routeHost r == hostText) (configRoutes config) +selectRoute + :: Config + -> Maybe BS.ByteString + -> BS.ByteString + -> Maybe (Text, PathRoute) +selectRoute config hostHeader requestPath = case hostHeader of + Nothing -> Nothing + Just host -> do + let hostText = TE.decodeUtf8 host + matchingRoutes = filter (\r -> routeHost r == hostText) + (configRoutes config) + route <- listToMaybe matchingRoutes + let requestPathText = TE.decodeUtf8 requestPath + sortedPaths = sortBy + (comparing (negate . T.length . pathRoutePath)) + (routePaths route) + matchingPaths = filter + (\p -> pathMatches (pathRoutePath p) requestPathText) + sortedPaths + pathRoute <- listToMaybe matchingPaths + return (pathRouteUpstream pathRoute, pathRoute) - -- Find first matching path within the route - route <- listToMaybe matchingRoutes - let requestPathText = TE.decodeUtf8 requestPath - -- Sort paths by length (longest first) so more specific paths match first - sortedPaths = sortBy (comparing (negate . T.length . pathRoutePath)) (routePaths route) - matchingPaths = filter (\p -> pathMatches (pathRoutePath p) requestPathText) sortedPaths - - pathRoute <- listToMaybe matchingPaths - return (pathRouteUpstream pathRoute, pathRoute) - --- | Check if a path pattern matches a request path pathMatches :: Text -> Text -> Bool pathMatches pattern requestPath = pattern == "/" || T.isPrefixOf pattern requestPath --- | Select an upstream for a request (exported for testing) -selectUpstream :: Config -> Maybe BS.ByteString -> BS.ByteString -> Maybe Text +selectUpstream + :: Config -> Maybe BS.ByteString -> BS.ByteString -> Maybe Text selectUpstream config hostHeader requestPath = - fmap fst $ selectRoute config hostHeader requestPath + fst <$> selectRoute config hostHeader requestPath --- | Forward request to backend server with streaming support -forwardRequest :: Manager -> Request -> Text -> (Response -> IO ResponseReceived) -> IO ResponseReceived +forwardRequest + :: Manager + -> Request + -> Text + -> (Response -> IO ResponseReceived) + -> IO ResponseReceived forwardRequest manager clientReq backendHost respond = do - let backendUrl = "http://" ++ T.unpack backendHost ++ - BS8.unpack (rawPathInfo clientReq) ++ - BS8.unpack (rawQueryString clientReq) + let backendUrl = httpScheme ++ T.unpack backendHost + ++ BS8.unpack (rawPathInfo clientReq) + ++ BS8.unpack (rawQueryString clientReq) initReq <- parseRequest backendUrl @@ -536,56 +585,53 @@ forwardRequest manager clientReq backendHost respond = do backendReq = initReq { HTTP.method = requestMethod clientReq - , HTTP.requestHeaders = filterHeaders (requestHeaders clientReq) + , HTTP.requestHeaders = filterRequestHeaders (requestHeaders clientReq) , HTTP.requestBody = streamingBody } - let upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig + upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig * microsPerSecond + mResult <- timeout upstreamMicros $ withResponse backendReq manager $ \backendResponse -> do - let status = HTTP.responseStatus backendResponse - headers = HTTP.responseHeaders backendResponse + let status = HTTP.responseStatus backendResponse + headers = HTTP.responseHeaders backendResponse bodyReader = HTTP.responseBody backendResponse - if shouldStreamResponse headers then do hPutStrLn stderr "[STREAM] Streaming response detected" - respond $ responseStream status (filterResponseHeaders headers) $ \write flush -> do - let loop = do - chunk <- brRead bodyReader - unless (BS.null chunk) $ do - write (byteString chunk) - flush - loop - loop + respond $ responseStream status (filterResponseHeaders headers) $ + \write flush -> do + let loop = do + chunk <- brRead bodyReader + unless (BS.null chunk) $ do + write (byteString chunk) + flush + loop + loop else do body <- readFullBody bodyReader respond $ responseLBS status (filterResponseHeaders headers) body + case mResult of - Just rr -> return rr + Just rr -> pure rr Nothing -> respond $ responseLBS status504 - [("Content-Type", "text/plain")] - "504 Gateway Timeout: upstream did not respond in time" + [(hContentType, contentTypePlain)] + bodyGatewayTimeout shouldStreamResponse :: [(HeaderName, BS.ByteString)] -> Bool -shouldStreamResponse headers = - isSSE || isChunkedWithoutLength +shouldStreamResponse headers = isSSE || isChunkedWithoutLength where isSSE = case lookup "Content-Type" headers of - Just ct -> "text/event-stream" `BS.isInfixOf` ct + Just ct -> eventStreamContentType `BS.isInfixOf` ct Nothing -> False - - isChunkedWithoutLength = - hasChunkedEncoding && not hasContentLength - + isChunkedWithoutLength = hasChunkedEncoding && not hasContentLength hasChunkedEncoding = case lookup "Transfer-Encoding" headers of - Just te -> "chunked" `BS.isInfixOf` te + Just te -> chunkedEncoding `BS.isInfixOf` te Nothing -> False - hasContentLength = case lookup "Content-Length" headers of - Just _ -> True + Just _ -> True Nothing -> False readFullBody :: HTTP.BodyReader -> IO LBS.ByteString @@ -594,45 +640,17 @@ readFullBody bodyReader = LBS.fromChunks <$> go go = do chunk <- brRead bodyReader if BS.null chunk - then return [] + then pure [] else do rest <- go - return (chunk : rest) + pure (chunk : rest) -filterResponseHeaders :: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)] -filterResponseHeaders = filter (\(name, _) -> name `notElem` hopByHopHeaders) - where - hopByHopHeaders = - [ "Transfer-Encoding" - , "Connection" - , "Keep-Alive" - ] +filterResponseHeaders + :: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)] +filterResponseHeaders = + filter (\(name, _) -> name `notElem` hopByHopResponseHeaders) --- | Filter headers for regular HTTP (remove hop-by-hop headers) -filterHeaders :: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)] -filterHeaders headers = filter (\(name, _) -> name `notElem` hopByHopHeaders) headers - where - hopByHopHeaders = - [ "Connection" - , "Keep-Alive" - , "Proxy-Authenticate" - , "Proxy-Authorization" - , "TE" - , "Trailers" - , "Transfer-Encoding" - , "Upgrade" - ] - --- | Log incoming request -logRequest :: Request -> IO () -logRequest req = do - let method' = BS8.unpack (requestMethod req) - path = BS8.unpack (rawPathInfo req) - query = BS8.unpack (rawQueryString req) - host = fromMaybe "unknown" $ lookup "Host" (requestHeaders req) - - putStrLn $ "[→] " ++ method' ++ " " ++ path ++ query ++ " (Host: " ++ BS8.unpack host ++ ")" - --- Helper: zipWithM -zipWithM :: Monad m => (a -> b -> m c) -> [a] -> [b] -> m [c] -zipWithM f xs ys = sequence (zipWith f xs ys) +filterRequestHeaders + :: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)] +filterRequestHeaders = + filter (\(name, _) -> name `notElem` hopByHopRequestHeaders) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/RateLimit.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/RateLimit.hs index 6f125c4a..4963c805 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/RateLimit.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/RateLimit.hs @@ -31,6 +31,7 @@ import Control.Concurrent.STM ) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as BS8 +import Data.List (intercalate) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Text (Text) @@ -175,12 +176,7 @@ clientIPKey req = case remoteHost req of v6Bytes ha = let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple ha parts = [a, b, c, d, e, f, g, h] - in BS8.pack (joinColons (map (`showHex` "") parts)) - - joinColons :: [String] -> String - joinColons [] = "" - joinColons [x] = x - joinColons (x : xs) = x <> ":" <> joinColons xs + in BS8.pack (intercalate ":" (map (`showHex` "") parts)) pathClassKey :: Request -> ByteString pathClassKey req = diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/WAF/Engine.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/WAF/Engine.hs index 0bd0fe5f..17eff80b 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/WAF/Engine.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/WAF/Engine.hs @@ -39,7 +39,7 @@ import Data.CaseInsensitive (CI) import qualified Data.CaseInsensitive as CI import Data.Char (toLower) import Data.Word (Word32) -import Network.HTTP.Types (Status, mkStatus) +import Network.HTTP.Types (status403) import Network.HTTP.Types.URI (urlDecode) import Network.Wai ( Middleware @@ -159,9 +159,6 @@ decisionFromScore hasBlock score threshold matches wafResponseHeader :: CI ByteString wafResponseHeader = "x-aenebris-waf" -status403 :: Status -status403 = mkStatus 403 "Forbidden" - wafMiddleware :: TVar RuleSet -> Middleware wafMiddleware rsVar app req respond = do rs <- readTVarIO rsVar diff --git a/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs b/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs index d0914406..954a642d 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs @@ -10,10 +10,13 @@ import Aenebris.Backend ( createRuntimeBackend , getConnectionCount , isHealthy + , rbActiveConnections + , rbConsecutiveFailures , rbServerId , rbWeight , recordFailure , recordSuccess + , trackConnection , transitionToHealthy , transitionToRecovering , transitionToUnhealthy @@ -30,7 +33,8 @@ import Aenebris.Config , validateConfig ) import Aenebris.DDoS.ConnLimit - ( defaultConnLimitConfig + ( currentCount + , defaultConnLimitConfig , defaultPerIPLimit , ipBytesFromSockAddr , newConnLimiter @@ -262,6 +266,7 @@ import Control.Concurrent.STM ( atomically , modifyTVar' , newTVarIO + , readTVar , readTVarIO ) import qualified Data.ByteString as BS @@ -477,31 +482,46 @@ loadBalancerSpec = describe "LoadBalancer" $ do lb <- createLoadBalancer RoundRobin [] selectBackend lb `shouldReturn` Nothing - it "selects from backend pool with round robin" $ do + it "round robin distributes evenly across the pool" $ do bks <- mapM (\(i, h) -> createRuntimeBackend i (Server h 1)) [(0, "host-a:80"), (1, "host-b:80"), (2, "host-c:80")] lb <- createLoadBalancer RoundRobin bks - let getName = fmap (fmap rbServerId) (selectBackend lb) - a <- getName - b <- getName - c <- getName - isJust a `shouldBe` True - isJust b `shouldBe` True - isJust c `shouldBe` True + let totalRounds = 9 :: Int + selections <- mapM + (\_ -> fmap (fmap rbServerId) (selectBackend lb)) + [1 .. totalRounds] + let counts = + [ length (filter (== Just sid) selections) + | sid <- [0, 1, 2] + ] + counts `shouldBe` [3, 3, 3] - it "selects backend with weighted round robin" $ do + it "weighted round robin selects proportionally" $ do bks <- mapM (\(i, h, w) -> createRuntimeBackend i (Server h w)) [(0, "host-a:80", 1), (1, "host-b:80", 4)] lb <- createLoadBalancer WeightedRoundRobin bks - selected <- selectBackend lb - isJust selected `shouldBe` True + let totalRounds = 50 :: Int + selections <- mapM + (\_ -> fmap (fmap rbServerId) (selectBackend lb)) + [1 .. totalRounds] + let countA = length (filter (== Just 0) selections) + countB = length (filter (== Just 1) selections) + countB `shouldSatisfy` (>= 35) + countA `shouldSatisfy` (<= 15) - it "selects least connections backend" $ do + it "least connections picks the backend with fewest active connections" $ do bks <- mapM (\(i, h) -> createRuntimeBackend i (Server h 1)) [(0, "host-a:80"), (1, "host-b:80")] - lb <- createLoadBalancer LeastConnections bks - selected <- selectBackend lb - isJust selected `shouldBe` True + case bks of + [a, _b] -> do + atomically $ + modifyTVar' + (rbActiveConnections a) + (+ 5) + lb <- createLoadBalancer LeastConnections bks + selected <- selectBackend lb + fmap rbServerId selected `shouldBe` Just 1 + _ -> expectationFailure "expected exactly two backends" backendSpec :: Spec backendSpec = describe "Backend" $ do @@ -526,17 +546,21 @@ backendSpec = describe "Backend" $ do bk <- createRuntimeBackend 0 (Server "host:80" 1) atomically (getConnectionCount bk) `shouldReturn` 0 - it "tolerates repeated failures" $ do + it "transitions to Unhealthy after maxFailures consecutive failures" $ do bk <- createRuntimeBackend 0 (Server "host:80" 10) - atomically $ recordFailure bk 3 - atomically $ recordFailure bk 3 - atomically $ recordFailure bk 3 - rbWeight bk `shouldBe` 10 + atomically (recordFailure bk 3) + atomically (isHealthy bk) `shouldReturn` True + atomically (recordFailure bk 3) + atomically (isHealthy bk) `shouldReturn` True + atomically (recordFailure bk 3) + atomically (isHealthy bk) `shouldReturn` False - it "records successes without crashing" $ do + it "recordSuccess on Healthy resets the failure counter" $ do bk <- createRuntimeBackend 0 (Server "host:80" 5) - atomically $ recordSuccess bk 5 - pure () + atomically (recordFailure bk 5) + atomically (readTVar (rbConsecutiveFailures bk)) `shouldReturn` 1 + atomically (recordSuccess bk 5) + atomically (readTVar (rbConsecutiveFailures bk)) `shouldReturn` 0 securitySpec :: Spec securitySpec = describe "Security headers" $ do @@ -752,11 +776,12 @@ connLimitSpec = describe "ConnLimit" $ do res <- atomically (tryAcquire cl "9.9.9.9") res `shouldBe` False - it "release decrements counter" $ do + it "release decrements counter back to 0" $ do cl <- newConnLimiter defaultConnLimitConfig _ <- atomically (tryAcquire cl "1.2.3.4") + atomically (currentCount cl "1.2.3.4") `shouldReturn` 1 atomically (release cl "1.2.3.4") - pure () + atomically (currentCount cl "1.2.3.4") `shouldReturn` 0 ja4hSpec :: Spec ja4hSpec = describe "JA4H fingerprint" $ do