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.
This commit is contained in:
CarterPerez-dev 2026-04-29 02:12:00 -04:00
parent 2c7e743f57
commit 998b5268e0
15 changed files with 780 additions and 829 deletions

View File

@ -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

View File

@ -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

View File

@ -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 ()

View File

@ -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]))

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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) []

View File

@ -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)

View File

@ -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 =

View File

@ -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

View File

@ -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