Merge pull request #199 from CarterPerez-dev/chore/haskell-reverse-proxy-finish

Chore/haskell reverse proxy finish
This commit is contained in:
Carter Perez 2026-04-29 02:55:51 -04:00 committed by GitHub
commit 09542d3a83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1362 additions and 1122 deletions

View File

@ -59,8 +59,22 @@ class SurrealDBManager:
settings.SURREAL_DATABASE,
)
await self._init_schema()
self._connected = True
async def _init_schema(self) -> None:
"""
Define tables used by the application so empty SELECTs do not error
"""
schema = """
DEFINE TABLE IF NOT EXISTS rooms SCHEMALESS;
DEFINE TABLE IF NOT EXISTS room_participants SCHEMALESS;
DEFINE TABLE IF NOT EXISTS messages SCHEMALESS;
DEFINE TABLE IF NOT EXISTS presence SCHEMALESS;
"""
await self.db.query(schema)
async def disconnect(self) -> None:
"""
Close SurrealDB connection

View File

@ -22,9 +22,9 @@ services:
restart: always
surrealdb:
image: surrealdb/surrealdb:latest
image: surrealdb/surrealdb:v3.0.5
container_name: chat-surrealdb
command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} file:/data/database.db
command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} rocksdb:/data/database.db
environment:
- SURREAL_USER=root
- SURREAL_PASS=${SURREAL_PASSWORD}

View File

@ -1,17 +1,18 @@
# ©AngelaMos | 2025
# Development Vite Dockerfile
# HMR dev server, volume mounts for code
# ©AngelaMos | 2026
# vite.docker
FROM node:22-alpine
RUN corepack enable
WORKDIR /app
COPY frontend/package*.json ./
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN npm ci
RUN pnpm install --frozen-lockfile
COPY frontend/ .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@ -1,18 +1,19 @@
# ©AngelaMos | 2025
# Production Vite Dockerfile
# Multi-stage: build with node, serve with nginx
# ©AngelaMos | 2026
# vite.docker
FROM node:22-alpine AS builder
RUN corepack enable
WORKDIR /app
COPY frontend/package*.json ./
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN npm ci
RUN pnpm install --frozen-lockfile
COPY frontend/ .
RUN npm run build
RUN pnpm run build
FROM nginx:alpine

View File

@ -13,7 +13,7 @@ services:
volumes:
- postgres_dev_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
- "${POSTGRES_HOST_PORT:-54332}:5432"
networks:
- chat_network_dev
healthcheck:
@ -23,17 +23,17 @@ services:
retries: 5
surrealdb:
image: surrealdb/surrealdb:latest
image: surrealdb/surrealdb:v3.0.5
container_name: chat-surrealdb-dev
user: "0:0"
command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} file:/data/database.db
command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} rocksdb:/data/database.db
environment:
- SURREAL_USER=root
- SURREAL_PASS=${SURREAL_PASSWORD}
volumes:
- surreal_dev_data:/data
ports:
- "${SURREAL_HOST_PORT:-8001}:8000"
- "${SURREAL_HOST_PORT:-7781}:8000"
networks:
- chat_network_dev
healthcheck:
@ -55,7 +55,7 @@ services:
volumes:
- redis_dev_data:/data
ports:
- "${REDIS_HOST_PORT:-6379}:6379"
- "${REDIS_HOST_PORT:-43434}:6379"
networks:
- chat_network_dev
healthcheck:
@ -82,7 +82,7 @@ services:
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
RP_ID: ${RP_ID:-localhost}
RP_NAME: ${RP_NAME:-Encrypted P2P Chat}
RP_ORIGIN: ${RP_ORIGIN:-http://localhost:82}
RP_ORIGIN: ${RP_ORIGIN:-http://localhost:32342}
CORS_ORIGINS: ${CORS_ORIGINS}
volumes:
- ./backend:/app
@ -98,7 +98,7 @@ services:
networks:
- chat_network_dev
ports:
- "${BACKEND_HOST_PORT:-8000}:8000"
- "${BACKEND_HOST_PORT:-30200}:8000"
restart: unless-stopped
frontend:
@ -118,14 +118,14 @@ services:
networks:
- chat_network_dev
ports:
- "${FRONTEND_DEV_PORT:-5173}:5173"
- "${FRONTEND_DEV_PORT:-34343}:5173"
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: chat-nginx-dev
ports:
- "${NGINX_HTTP_PORT:-80}:80"
- "${NGINX_HTTP_PORT:-3234}:80"
volumes:
- ./conf/nginx/dev.nginx:/etc/nginx/nginx.conf:ro
- ./conf/nginx/http.conf:/etc/nginx/http.conf:ro

View File

@ -9,7 +9,7 @@ license: MIT
license-file: LICENSE
author: Carter Perez
maintainer: support@certgames.com
copyright: 2025 Carter Perez
copyright: 2026 AngelaMos
category: Network, Security, Web
build-type: Simple
extra-source-files: README.md
@ -38,6 +38,7 @@ library
, Aenebris.WAF.Engine
, Aenebris.Honeypot
, Aenebris.Geo
, Aenebris.Net.IP
, Aenebris.ML.Features
, Aenebris.ML.Model
, Aenebris.ML.Loader

View File

@ -1,47 +1,60 @@
{-
©AngelaMos | 2026
Main.hs
-}
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Aenebris.Config
import Aenebris.Connection
( defaultTimeoutConfig
, microsPerSecond
, tcUpstreamReadSeconds
)
import Aenebris.Proxy
import Network.HTTP.Client (newManager, defaultManagerSettings)
import Network.HTTP.Client
( ManagerSettings(..)
, defaultManagerSettings
, newManager
, responseTimeoutMicro
)
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"
-- Create HTTP client manager with connection pooling
manager <- newManager defaultManagerSettings
-- 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

@ -1,3 +1,8 @@
{-
©AngelaMos | 2026
Connection.hs
-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
@ -10,14 +15,14 @@ module Aenebris.Connection
, isWebSocketUpgrade
, isStreamingResponse
, getTimeout
, microsPerSecond
, httpOkStatusCode
) where
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
import Data.Maybe (isJust, fromMaybe)
import Data.Maybe (isJust)
import Network.HTTP.Types (HeaderName, Status, statusCode)
data ConnectionState
@ -35,23 +40,31 @@ data ConnectionType
| ChunkedStream
deriving (Eq, Show)
microsPerSecond :: Int
microsPerSecond = 1_000_000
httpOkStatusCode :: Int
httpOkStatusCode = 200
data TimeoutConfig = TimeoutConfig
{ tcHttpIdle :: Int
, tcWebSocketTunnel :: Int
, tcStreamingResponse :: Int
, tcProxyPingInterval :: Int
, tcPongTimeout :: Int
, tcConnectTimeout :: Int
{ tcHttpIdle :: !Int
, tcWebSocketTunnel :: !Int
, tcStreamingResponse :: !Int
, tcProxyPingInterval :: !Int
, tcPongTimeout :: !Int
, tcConnectTimeout :: !Int
, tcUpstreamReadSeconds :: !Int
}
defaultTimeoutConfig :: TimeoutConfig
defaultTimeoutConfig = TimeoutConfig
{ tcHttpIdle = 60
, tcWebSocketTunnel = 3600
, tcStreamingResponse = 3600
, tcProxyPingInterval = 30
, tcPongTimeout = 10
, tcConnectTimeout = 5
{ tcHttpIdle = 60
, tcWebSocketTunnel = 3600
, tcStreamingResponse = 3600
, tcProxyPingInterval = 30
, tcPongTimeout = 10
, tcConnectTimeout = 5
, tcUpstreamReadSeconds = 30
}
getTimeout :: TimeoutConfig -> ConnectionState -> Int
@ -96,4 +109,7 @@ isStreamingResponse status headers =
hasContentLength = isJust (lookup "Content-Length" headers)
isUnknownLength = statusCode status == 200 && not hasContentLength && not hasTransferEncodingChunked
isUnknownLength =
statusCode status == httpOkStatusCode
&& not hasContentLength
&& not hasTransferEncodingChunked

View File

@ -28,18 +28,11 @@ import Control.Concurrent.STM
, readTVar
, writeTVar
)
import Aenebris.Net.IP (sockAddrToIPBytes)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Network.Socket
( HostAddress6
, SockAddr(..)
, hostAddress6ToTuple
, hostAddressToTuple
)
import Numeric (showHex)
import Text.Printf (printf)
import Network.Socket (SockAddr)
defaultPerIPLimit :: Int
defaultPerIPLimit = 16
@ -86,24 +79,10 @@ currentCount ConnLimiter{..} ip = do
pure (Map.findWithDefault 0 ip m)
connLimitOnOpen :: ConnLimiter -> SockAddr -> IO Bool
connLimitOnOpen cl sa = atomically (tryAcquire cl (ipBytesFromSockAddr sa))
connLimitOnOpen cl sa = atomically (tryAcquire cl (sockAddrToIPBytes sa))
connLimitOnClose :: ConnLimiter -> SockAddr -> IO ()
connLimitOnClose cl sa = atomically (release cl (ipBytesFromSockAddr sa))
connLimitOnClose cl sa = atomically (release cl (sockAddrToIPBytes sa))
ipBytesFromSockAddr :: SockAddr -> ByteString
ipBytesFromSockAddr (SockAddrInet _ ha) =
let (a, b, c, d) = hostAddressToTuple ha
in BS8.pack (printf "%d.%d.%d.%d" a b c d)
ipBytesFromSockAddr (SockAddrInet6 _ _ ha6 _) = v6Bytes ha6
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
ipBytesFromSockAddr = sockAddrToIPBytes

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

@ -60,9 +60,11 @@ import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy as LBS
import Data.GeoIP2 (GeoDB, GeoResult(..), AS(..), findGeoData, openGeoDB)
import Data.IP (IP(..), fromHostAddress, fromHostAddress6)
import Data.List (minimumBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, isJust)
import Data.Ord (comparing)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
@ -95,6 +97,9 @@ defaultGeoSweepIntervalMicros = 60_000_000
defaultGeoFlaggedAsns :: [Int]
defaultGeoFlaggedAsns = []
defaultGeoAsnCountCap :: Int
defaultGeoAsnCountCap = 200_000
geoResponseHeaderName :: HeaderName
geoResponseHeaderName = "x-aenebris-geo"
@ -262,9 +267,20 @@ bumpAsnCounter Geo{..} n now = do
| now - awWindowStart w < window ->
w { awCount = awCount w + 1 }
_ -> AsnWindow { awCount = 1, awWindowStart = now }
writeTVar geoAsnCounts $! Map.insert n entry m
inserted = Map.insert n entry m
bounded = capAsnCounts inserted
writeTVar geoAsnCounts $! bounded
pure (awCount entry)
capAsnCounts :: Map Int AsnWindow -> Map Int AsnWindow
capAsnCounts m
| Map.size m <= defaultGeoAsnCountCap = m
| otherwise =
let oldestKey = fst $ minimumBy
(comparing (awWindowStart . snd))
(Map.toList m)
in Map.delete oldestKey m
asnConcentrationScore :: Geo -> Int -> Double
asnConcentrationScore Geo{..} count =
let threshold = max 1 (gcConcentrationThreshold geoConfig)

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"
@ -280,7 +274,7 @@ robotsResponse cfg =
robotsTxtBody :: HoneypotConfig -> ByteString
robotsTxtBody HoneypotConfig{..} = BS.concat $
[ "User-agent: *\n"
, "# Honeypot trap paths Disallow per RFC 9309. Visiting these\n"
, "# Honeypot trap paths. Disallow per RFC 9309. Visiting these\n"
, "# paths is treated as a violation signal regardless of declared UA.\n"
] <> map disallowLine hpPatterns
where

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

@ -16,6 +16,7 @@ module Aenebris.ML.IForest
, minSubsampleForNormalization
, defaultIForestNumTrees
, defaultIForestSubsampleSize
, maxIForestDepth
) where
import Data.Vector (Vector)
@ -44,6 +45,12 @@ defaultIForestNumTrees = 100
defaultIForestSubsampleSize :: Int
defaultIForestSubsampleSize = 256
maxIForestDepth :: Int
maxIForestDepth = 64
depthBoundLeafSize :: Int
depthBoundLeafSize = 1
data ITree
= ITreeLeaf !Int
| ITreeSplit !Int !Double !ITree !ITree
@ -77,14 +84,17 @@ averagePathLength !trees !fv =
addPath !acc !tree = acc + pathLength tree fv initialDepth
pathLength :: ITree -> VU.Vector Double -> Int -> Double
pathLength !tree !fv !currentDepth = case tree of
ITreeLeaf !size ->
fromIntegral currentDepth + normalizationConstant size
ITreeSplit !featIdx !thr !left !right ->
let !fval = fv VU.! featIdx
in if fval <= thr
then pathLength left fv (currentDepth + 1)
else pathLength right fv (currentDepth + 1)
pathLength !tree !fv !currentDepth
| currentDepth >= maxIForestDepth =
fromIntegral currentDepth + normalizationConstant depthBoundLeafSize
| otherwise = case tree of
ITreeLeaf !size ->
fromIntegral currentDepth + normalizationConstant size
ITreeSplit !featIdx !thr !left !right ->
let !fval = fv VU.! featIdx
in if fval <= thr
then pathLength left fv (currentDepth + 1)
else pathLength right fv (currentDepth + 1)
normalizationConstant :: Int -> Double
normalizationConstant n

View File

@ -44,6 +44,12 @@ requiredNumClass = 1
requiredNumTreePerIteration :: Int
requiredNumTreePerIteration = 1
maxNumLeaves :: Int
maxNumLeaves = 4096
maxNumTrees :: Int
maxNumTrees = 10000
postParseLineSentinel :: Int
postParseLineSentinel = -1
@ -355,18 +361,26 @@ requireField key mv = case mv of
("Missing required header key: " <> key))
runTrees :: [(Int, Text)] -> Either ParseError [Tree]
runTrees lns = case dropWhile (lineIsBlank . snd) lns of
[] -> Right []
((n, ln):rest)
| T.strip ln == endOfTreesMarker -> Right []
| T.isPrefixOf baseTreeKeyPrefix (T.strip ln) -> do
let (block, after) = break (lineIsBlank . snd) rest
tree <- parseTreeBlock block
more <- runTrees after
Right (tree : more)
| otherwise -> Left
(ParseError n treeKey
("Expected 'Tree=N' or 'end of trees', got: " <> ln))
runTrees = goTrees 0
where
goTrees :: Int -> [(Int, Text)] -> Either ParseError [Tree]
goTrees treeCount lns
| treeCount > maxNumTrees = Left
(ParseError postParseLineSentinel treeKey
("Tree count exceeds maxNumTrees "
<> T.pack (show maxNumTrees)))
| otherwise = case dropWhile (lineIsBlank . snd) lns of
[] -> Right []
((n, ln):rest)
| T.strip ln == endOfTreesMarker -> Right []
| T.isPrefixOf baseTreeKeyPrefix (T.strip ln) -> do
let (block, after) = break (lineIsBlank . snd) rest
tree <- parseTreeBlock block
more <- goTrees (treeCount + 1) after
Right (tree : more)
| otherwise -> Left
(ParseError n treeKey
("Expected 'Tree=N' or 'end of trees', got: " <> ln))
parseTreeBlock :: [(Int, Text)] -> Either ParseError Tree
parseTreeBlock block = do
@ -428,6 +442,14 @@ assignTreeField acc n key val
finalizeTree :: TreeAcc -> Either ParseError Tree
finalizeTree acc = do
nL <- requireField keyNumLeaves (taNumLeaves acc)
unless (nL > 0)
(Left (ParseError postParseLineSentinel keyNumLeaves
("num_leaves must be positive: " <> T.pack (show nL))))
unless (nL <= maxNumLeaves)
(Left (ParseError postParseLineSentinel keyNumLeaves
("num_leaves " <> T.pack (show nL)
<> " exceeds maxNumLeaves "
<> T.pack (show maxNumLeaves))))
nC <- requireField keyNumCat (taNumCat acc)
leafValues <- requireField keyLeafValue (taLeafValue acc)
unless (length leafValues == nL)

View File

@ -54,7 +54,7 @@ challengeWireText :: ByteString
challengeWireText = "challenge"
botBlockBody :: LBS.ByteString
botBlockBody = "403 Forbidden \x2014 request blocked by Aenebris ML"
botBlockBody = "403 Forbidden - request blocked by Aenebris ML"
challengePageBody :: LBS.ByteString
challengePageBody =

View File

@ -329,15 +329,19 @@ validateCategoricalNode
:: Int -> Int -> Tree -> Int -> Int -> Int -> Int -> Either String ()
validateCategoricalNode featureCount nodeCount t i fIdx lIdx rIdx = do
validateSplitNode featureCount nodeCount i fIdx lIdx rIdx
let catIdx = floor (treeThreshold t VU.! i) :: Int
nBound = VU.length (treeCatBoundaries t)
if nBound < 2
let rawThreshold = treeThreshold t VU.! i
catIdx = floor rawThreshold :: Int
nBound = VU.length (treeCatBoundaries t)
if fromIntegral catIdx /= rawThreshold
then Left ("Categorical node " <> show i
<> " requires non-empty cat_boundaries")
else if catIdx < 0 || catIdx >= nBound - 1
<> " has non-integer threshold " <> show rawThreshold)
else if nBound < 2
then Left ("Categorical node " <> show i
<> " has out-of-range bitmap slice index " <> show catIdx)
else Right ()
<> " requires non-empty cat_boundaries")
else if catIdx < 0 || catIdx >= nBound - 1
then Left ("Categorical node " <> show i
<> " has out-of-range bitmap slice index " <> show catIdx)
else Right ()
validateEnsemble :: Int -> Ensemble -> Either String ()
validateEnsemble expectedFeatures ens = do

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

@ -0,0 +1,43 @@
{-
©AngelaMos | 2026
IP.hs
-}
{-# LANGUAGE OverloadedStrings #-}
module Aenebris.Net.IP
( sockAddrToIPBytes
) where
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.List (intercalate)
import Network.Socket
( HostAddress6
, SockAddr(..)
, hostAddress6ToTuple
, hostAddressToTuple
)
import Numeric (showHex)
import Text.Printf (printf)
ipv4Format :: String
ipv4Format = "%d.%d.%d.%d"
ipv6Separator :: String
ipv6Separator = ":"
unixSocketPrefix :: String
unixSocketPrefix = "unix:"
sockAddrToIPBytes :: SockAddr -> ByteString
sockAddrToIPBytes (SockAddrInet _ ha) =
let (a, b, c, d) = hostAddressToTuple ha
in BS8.pack (printf ipv4Format a b c d)
sockAddrToIPBytes (SockAddrInet6 _ _ ha6 _) = renderIPv6 ha6
sockAddrToIPBytes (SockAddrUnix p) = BS8.pack (unixSocketPrefix <> p)
renderIPv6 :: HostAddress6 -> ByteString
renderIPv6 ha =
let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple ha
parts = [a, b, c, d, e, f, g, h]
in BS8.pack (intercalate ipv6Separator (map (`showHex` "") parts))

View File

@ -1,6 +1,10 @@
{-
©AngelaMos | 2026
Proxy.hs
-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Aenebris.Proxy
( ProxyState(..)
@ -13,13 +17,24 @@ module Aenebris.Proxy
import Aenebris.Backend
import Aenebris.Config
import Aenebris.Connection
( ConnectionType(..)
, defaultTimeoutConfig
, detectConnectionType
, microsPerSecond
, tcUpstreamReadSeconds
)
import Aenebris.HealthCheck
import Aenebris.LoadBalancer
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
@ -66,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)
@ -76,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
@ -93,34 +118,80 @@ import Network.Wai.Handler.Warp
, setTimeout
)
import Network.Wai.Handler.WarpTLS (runTLS)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import System.Timeout (timeout)
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://"
-- | Proxy runtime state
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
@ -134,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)
@ -162,84 +234,83 @@ 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
[] -> error "No listen ports configured"
[] -> do
hPutStrLn stderr "ERROR: No listen ports configured"
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
@ -254,260 +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 "ERROR: Failed to load TLS certificate"
hPutStrLn stderr $ " " ++ show err
error "TLS configuration error"
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
_ -> error "TLS configuration error: cert and key required"
-- | 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 "ERROR: Failed to load SNI certificates"
hPutStrLn stderr $ " " ++ show err
error "SNI configuration error"
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"
exitFailure
_ -> error "SNI configuration error: sni, default_cert, and default_key required"
-- | 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
@ -521,47 +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
}
withResponse backendReq manager $ \backendResponse -> do
let status = HTTP.responseStatus backendResponse
headers = HTTP.responseHeaders backendResponse
bodyReader = HTTP.responseBody backendResponse
upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig
* microsPerSecond
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
else do
body <- readFullBody bodyReader
respond $ responseLBS status (filterResponseHeaders headers) body
mResult <- timeout upstreamMicros $
withResponse backendReq manager $ \backendResponse -> do
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
else do
body <- readFullBody bodyReader
respond $ responseLBS status (filterResponseHeaders headers) body
case mResult of
Just rr -> pure rr
Nothing -> respond $ responseLBS
status504
[(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
@ -570,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

@ -29,6 +29,7 @@ import Control.Concurrent.STM
, readTVar
, writeTVar
)
import Aenebris.Net.IP (sockAddrToIPBytes)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.Map.Strict (Map)
@ -38,12 +39,6 @@ import qualified Data.Text as T
import qualified Data.Text.Read as TR
import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime)
import Network.HTTP.Types (status429)
import Network.Socket
( HostAddress6
, SockAddr(..)
, hostAddress6ToTuple
, hostAddressToTuple
)
import Network.Wai
( Middleware
, Request
@ -51,8 +46,6 @@ import Network.Wai
, remoteHost
, responseLBS
)
import Numeric (showHex)
import Text.Printf (printf)
secondsPerMinute :: Double
secondsPerMinute = 60
@ -164,23 +157,7 @@ rateLimitMiddleware rl app req respond = do
intBS n = BS8.pack (show n)
clientIPKey :: Request -> ByteString
clientIPKey req = case remoteHost req of
SockAddrInet _ ha ->
let (a, b, c, d) = hostAddressToTuple ha
in BS8.pack (printf "%d.%d.%d.%d" a b c d)
SockAddrInet6 _ _ ha6 _ -> v6Bytes ha6
SockAddrUnix p -> BS8.pack ("unix:" <> p)
where
v6Bytes :: HostAddress6 -> ByteString
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
clientIPKey = sockAddrToIPBytes . remoteHost
pathClassKey :: Request -> ByteString
pathClassKey req =

View File

@ -1,3 +1,7 @@
{-
©AngelaMos | 2026
TLS.hs
-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
@ -8,23 +12,27 @@ module Aenebris.TLS
, createSNISettings
, validateCertificate
, CertificateError(..)
, strongCipherSuites
) where
import qualified Data.ByteString as BS
import Control.Exception (SomeException, try)
import qualified Data.ByteString.Lazy as LBS
import Data.Default.Class (def)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Map.Strict as Map
import Network.Wai.Handler.WarpTLS
import qualified Network.TLS as TLS
import qualified Network.TLS.Extra.Cipher as Cipher
import Data.Default.Class (def)
import Data.X509 (SignedCertificate)
import Data.X509.File (readSignedObject)
import qualified Network.TLS as TLS
import qualified Network.TLS.Extra.Cipher as Cipher
import Network.Wai.Handler.WarpTLS
import System.Directory (doesFileExist)
import Control.Exception (try, SomeException)
import System.IO (hPutStrLn, stderr)
httpsRequiredMessage :: LBS.ByteString
httpsRequiredMessage = "This server requires HTTPS"
-- | Certificate loading errors
data CertificateError
= CertFileNotFound FilePath
| KeyFileNotFound FilePath
@ -32,130 +40,128 @@ data CertificateError
| InvalidKey FilePath String
deriving (Show, Eq)
-- | Create TLS settings for a single certificate (non-SNI)
createTLSSettings :: FilePath -> FilePath -> IO (Either CertificateError TLSSettings)
createTLSSettings
:: FilePath
-> FilePath
-> IO (Either CertificateError TLSSettings)
createTLSSettings certFile keyFile = do
-- Validate files exist
certExists <- doesFileExist certFile
keyExists <- doesFileExist keyFile
keyExists <- doesFileExist keyFile
if not certExists
then return $ Left (CertFileNotFound certFile)
then pure (Left (CertFileNotFound certFile))
else if not keyExists
then return $ Left (KeyFileNotFound keyFile)
then pure (Left (KeyFileNotFound keyFile))
else do
-- Try to load the credential to validate it
result <- try $ TLS.credentialLoadX509 certFile keyFile
case result of
Left (err :: SomeException) ->
return $ Left (InvalidCertificate certFile (show err))
pure (Left (InvalidCertificate certFile (show err)))
Right (Left err) ->
return $ Left (InvalidCertificate certFile err)
pure (Left (InvalidCertificate certFile err))
Right (Right _) ->
pure (Right (configureTLS certFile keyFile))
Right (Right _credential) -> do
-- Create TLS settings with strong security
let tlsConfig = (tlsSettings certFile keyFile)
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = strongCipherSuites
, onInsecure = DenyInsecure "This server requires HTTPS"
}
configureTLS :: FilePath -> FilePath -> TLSSettings
configureTLS certFile keyFile = (tlsSettings certFile keyFile)
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = strongCipherSuites
, onInsecure = DenyInsecure httpsRequiredMessage
}
return $ Right tlsConfig
-- | Create TLS settings with SNI support for multiple domains
createSNISettings :: [(Text, FilePath, FilePath)] -> FilePath -> FilePath -> IO (Either CertificateError TLSSettings)
createSNISettings
:: [(Text, FilePath, FilePath)]
-> FilePath
-> FilePath
-> IO (Either CertificateError TLSSettings)
createSNISettings domains defaultCert defaultKey = do
-- Validate default certificate
defaultExists <- doesFileExist defaultCert
defaultKeyExists <- doesFileExist defaultKey
if not defaultExists
then return $ Left (CertFileNotFound defaultCert)
else if not defaultKeyExists
then return $ Left (KeyFileNotFound defaultKey)
defaultCertOk <- doesFileExist defaultCert
defaultKeyOk <- doesFileExist defaultKey
if not defaultCertOk
then pure (Left (CertFileNotFound defaultCert))
else if not defaultKeyOk
then pure (Left (KeyFileNotFound defaultKey))
else do
-- Validate all domain certificates exist
validationResults <- mapM validateDomainCert domains
case sequence validationResults of
Left err -> return $ Left err
Right _ -> do
-- Create SNI-enabled TLS settings using the default cert first
let baseTLS = tlsSettings defaultCert defaultKey
tlsConfig = baseTLS
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = strongCipherSuites
, onInsecure = DenyInsecure "This server requires HTTPS"
, tlsServerHooks = def
{ TLS.onServerNameIndication = \mHostname -> case mHostname of
Nothing -> loadCredentials defaultCert defaultKey
Just hostname -> sniCallback domains defaultCert defaultKey hostname
}
}
validations <- mapM validateDomainCert domains
case sequence validations of
Left err -> pure (Left err)
Right _ -> pure (Right (configureSNI domains defaultCert defaultKey))
return $ Right tlsConfig
where
validateDomainCert :: (Text, FilePath, FilePath) -> IO (Either CertificateError ())
validateDomainCert (domain, certFile, keyFile) = do
certExists <- doesFileExist certFile
keyExists <- doesFileExist keyFile
configureSNI
:: [(Text, FilePath, FilePath)]
-> FilePath
-> FilePath
-> TLSSettings
configureSNI domains defaultCert defaultKey =
let baseTLS = tlsSettings defaultCert defaultKey
in baseTLS
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
, tlsCiphers = strongCipherSuites
, onInsecure = DenyInsecure httpsRequiredMessage
, tlsServerHooks = def
{ TLS.onServerNameIndication = \mHostname -> case mHostname of
Nothing -> credentialsOrDefault defaultCert defaultKey
Just hostname ->
sniCallback domains defaultCert defaultKey hostname
}
}
if not certExists
then return $ Left (CertFileNotFound certFile)
else if not keyExists
then return $ Left (KeyFileNotFound keyFile)
else return $ Right ()
validateDomainCert
:: (Text, FilePath, FilePath) -> IO (Either CertificateError ())
validateDomainCert (_domain, certFile, keyFile) = do
certExists <- doesFileExist certFile
keyExists <- doesFileExist keyFile
if not certExists
then pure (Left (CertFileNotFound certFile))
else if not keyExists
then pure (Left (KeyFileNotFound keyFile))
else pure (Right ())
-- | SNI callback function - returns credentials based on hostname
sniCallback :: [(Text, FilePath, FilePath)] -> FilePath -> FilePath -> String -> IO TLS.Credentials
sniCallback domains defaultCert defaultKey hostname = do
let hostnameText = T.pack hostname
-- Look up domain in map
sniCallback
:: [(Text, FilePath, FilePath)]
-> FilePath
-> FilePath
-> String
-> IO TLS.Credentials
sniCallback domains defaultCert defaultKey hostname =
let domainMap :: Map Text (FilePath, FilePath)
domainMap = Map.fromList [(d, (c, k)) | (d, c, k) <- domains]
in case Map.lookup (T.pack hostname) domainMap of
Nothing -> credentialsOrDefault defaultCert defaultKey
Just (certFile, keyFile) ->
credentialsOrDefault certFile keyFile
case Map.lookup hostnameText domainMap of
Nothing -> do
-- No match, use default certificate
loadCredentials defaultCert defaultKey
Just (certFile, keyFile) -> do
-- Found matching domain, load its certificate
loadCredentials certFile keyFile
-- | Load TLS credentials from certificate and key files
loadCredentials :: FilePath -> FilePath -> IO TLS.Credentials
loadCredentials certFile keyFile = do
credentialsOrDefault :: FilePath -> FilePath -> IO TLS.Credentials
credentialsOrDefault certFile keyFile = do
result <- TLS.credentialLoadX509 certFile keyFile
case result of
Left err ->
error $ "Failed to load certificate: " ++ err
Left err -> do
hPutStrLn stderr $
"TLS: failed to load credential at "
<> certFile <> " (" <> err <> "); SNI handler returns empty credentials"
pure (TLS.Credentials [])
Right credential ->
return $ TLS.Credentials [credential]
pure (TLS.Credentials [credential])
-- | Validate a certificate file (check if it's readable and valid)
validateCertificate :: FilePath -> IO (Either CertificateError [SignedCertificate])
validateCertificate
:: FilePath -> IO (Either CertificateError [SignedCertificate])
validateCertificate certFile = do
exists <- doesFileExist certFile
if not exists
then return $ Left (CertFileNotFound certFile)
then pure (Left (CertFileNotFound certFile))
else do
result <- try $ readSignedObject certFile
case result of
Left (err :: SomeException) ->
return $ Left (InvalidCertificate certFile (show err))
pure (Left (InvalidCertificate certFile (show err)))
Right certs ->
return $ Right certs
pure (Right certs)
-- | Strong cipher suites for production (TLS 1.2 + TLS 1.3)
strongCipherSuites :: [TLS.Cipher]
strongCipherSuites =
-- TLS 1.3 cipher suites (preferred)
[ Cipher.cipher_TLS13_AES128GCM_SHA256
, Cipher.cipher_TLS13_AES256GCM_SHA384
, Cipher.cipher_TLS13_CHACHA20POLY1305_SHA256
] ++
-- TLS 1.2 cipher suites (fallback, only ECDHE + AEAD)
[ Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
[ Cipher.cipher13_AES_128_GCM_SHA256
, Cipher.cipher13_AES_256_GCM_SHA384
, Cipher.cipher13_CHACHA20_POLY1305_SHA256
, Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
, Cipher.cipher_ECDHE_RSA_WITH_AES_256_GCM_SHA384
, Cipher.cipher_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
, Cipher.cipher_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256

View File

@ -1,27 +1,95 @@
{-
©AngelaMos | 2026
Tunnel.hs
-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}
module Aenebris.Tunnel
( tunnelWebSocket
( ConnectError(..)
, connectToBackend
, parseHostPort
, parseUpgradeStatus
, tunnelWebSocket
, streamResponse
, bidirectionalCopy
) where
import Control.Concurrent.Async (race_)
import Control.Exception (SomeException, try, bracket)
import Control.Exception
( SomeException
, bracketOnError
, finally
, try
)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.CaseInsensitive (original)
import Data.Text (Text)
import qualified Data.Text as T
import Network.HTTP.Types (HeaderName)
import Network.Socket (Socket)
import qualified Network.Socket as Socket
import qualified Network.Socket.ByteString as SocketBS
import Network.Wai
( Request
, rawPathInfo
, rawQueryString
, requestHeaders
, requestMethod
)
import System.IO (hPutStrLn, stderr)
import System.Timeout (timeout)
defaultBackendPort :: Int
defaultBackendPort = 80
upgradeRecvChunkBytes :: Int
upgradeRecvChunkBytes = 4_096
maxUpgradeHeaderBytes :: Int
maxUpgradeHeaderBytes = 16_384
tunnelRecvChunkBytes :: Int
tunnelRecvChunkBytes = 65_536
connectTimeoutSeconds :: Int
connectTimeoutSeconds = 5
upgradeIdleSeconds :: Int
upgradeIdleSeconds = 30
microsPerSecond :: Int
microsPerSecond = 1_000_000
upgradeStatusSwitching :: Int
upgradeStatusSwitching = 101
badGatewayResponseLine :: ByteString
badGatewayResponseLine = "HTTP/1.1 502 Bad Gateway\r\n\r\n"
upgradeTerminator :: ByteString
upgradeTerminator = "\r\n\r\n"
httpHeaderLineEnd :: ByteString
httpHeaderLineEnd = "\r\n"
httpVersionAndCrlf :: ByteString
httpVersionAndCrlf = " HTTP/1.1\r\n"
httpFieldSeparator :: ByteString
httpFieldSeparator = ": "
requestPathSeparator :: ByteString
requestPathSeparator = " "
data ConnectError
= ResolutionFailed !String !Int
| ConnectTimeout !String !Int
| ConnectFailed !String !Int !String
deriving (Eq, Show)
tunnelWebSocket
:: Request
@ -31,39 +99,62 @@ tunnelWebSocket
-> IO ()
tunnelWebSocket clientReq backendHost clientSend clientRecv = do
hPutStrLn stderr $ "[WS] Initiating tunnel to " ++ T.unpack backendHost
result <- try $ do
let (host, port) = parseHostPort backendHost
bracket
(connectToBackend host port)
Socket.close
$ \backendSocket -> do
sendUpgradeRequest backendSocket clientReq
upgradeResponse <- receiveUpgradeResponse backendSocket
case parseUpgradeStatus upgradeResponse of
Just 101 -> do
hPutStrLn stderr "[WS] Backend accepted upgrade (101)"
clientSend upgradeResponse
bidirectionalCopy clientRecv clientSend
(SocketBS.recv backendSocket 65536)
(SocketBS.sendAll backendSocket)
Just code -> do
hPutStrLn stderr $ "[WS] Backend rejected upgrade: " ++ show code
clientSend upgradeResponse
Nothing -> do
hPutStrLn stderr "[WS] Invalid upgrade response"
clientSend "HTTP/1.1 502 Bad Gateway\r\n\r\n"
case result of
let (host, port) = parseHostPort backendHost
outcome <- try $ do
eSock <- connectToBackend host port
case eSock of
Left err -> do
hPutStrLn stderr $ "[WS] Connection failed: " ++ show err
clientSend badGatewayResponseLine
Right sock ->
runUpgrade clientReq clientSend clientRecv sock
`finally` Socket.close sock
case outcome of
Left (e :: SomeException) ->
hPutStrLn stderr $ "[WS] Tunnel error: " ++ show e
Right () ->
hPutStrLn stderr "[WS] Tunnel closed"
runUpgrade
:: Request
-> (ByteString -> IO ())
-> IO ByteString
-> Socket
-> IO ()
runUpgrade clientReq clientSend clientRecv sock = do
sendUpgradeRequest sock clientReq
mResponse <- timeout
(upgradeIdleSeconds * microsPerSecond)
(receiveUpgradeResponse sock)
case mResponse of
Nothing -> do
hPutStrLn stderr "[WS] Upgrade response timed out"
clientSend badGatewayResponseLine
Just upgradeResponse ->
dispatchUpgrade sock clientSend clientRecv upgradeResponse
dispatchUpgrade
:: Socket
-> (ByteString -> IO ())
-> IO ByteString
-> ByteString
-> IO ()
dispatchUpgrade sock clientSend clientRecv upgradeResponse =
case parseUpgradeStatus upgradeResponse of
Just code | code == upgradeStatusSwitching -> do
hPutStrLn stderr "[WS] Backend accepted upgrade (101)"
clientSend upgradeResponse
bidirectionalCopy
clientRecv clientSend
(SocketBS.recv sock tunnelRecvChunkBytes)
(SocketBS.sendAll sock)
Just code -> do
hPutStrLn stderr $ "[WS] Backend rejected upgrade: " ++ show code
clientSend upgradeResponse
Nothing -> do
hPutStrLn stderr "[WS] Invalid upgrade response"
clientSend badGatewayResponseLine
bidirectionalCopy
:: IO ByteString
-> (ByteString -> IO ())
@ -72,11 +163,9 @@ bidirectionalCopy
-> IO ()
bidirectionalCopy clientRecv clientSend backendRecv backendSend = do
hPutStrLn stderr "[TUNNEL] Starting bidirectional copy"
race_
(copyLoop "client->backend" clientRecv backendSend)
(copyLoop "backend->client" backendRecv clientSend)
hPutStrLn stderr "[TUNNEL] Bidirectional copy ended"
copyLoop :: String -> IO ByteString -> (ByteString -> IO ()) -> IO ()
@ -112,56 +201,74 @@ parseHostPort hostPort =
[host, portStr] ->
case reads (T.unpack portStr) of
[(port, "")] -> (T.unpack host, port)
_ -> (T.unpack host, 80)
[host] -> (T.unpack host, 80)
_ -> (T.unpack hostPort, 80)
_ -> (T.unpack host, defaultBackendPort)
[host] -> (T.unpack host, defaultBackendPort)
_ -> (T.unpack hostPort, defaultBackendPort)
connectToBackend :: String -> Int -> IO Socket
connectToBackend :: String -> Int -> IO (Either ConnectError Socket)
connectToBackend host port = do
addrInfos <- Socket.getAddrInfo
resolution <- try $ Socket.getAddrInfo
(Just Socket.defaultHints { Socket.addrSocketType = Socket.Stream })
(Just host)
(Just $ show port)
(Just (show port))
case resolution of
Left (_ :: SomeException) -> pure (Left (ResolutionFailed host port))
Right [] -> pure (Left (ResolutionFailed host port))
Right (addr : _) -> attemptConnect host port addr
case addrInfos of
[] -> error $ "Cannot resolve: " ++ host ++ ":" ++ show port
(addr:_) -> do
sock <- Socket.socket
(Socket.addrFamily addr)
Socket.Stream
Socket.defaultProtocol
Socket.connect sock (Socket.addrAddress addr)
return sock
attemptConnect
:: String -> Int -> Socket.AddrInfo -> IO (Either ConnectError Socket)
attemptConnect host port addr =
bracketOnError
(Socket.socket
(Socket.addrFamily addr)
Socket.Stream
Socket.defaultProtocol)
Socket.close
$ \sock -> do
mConnect <- timeout
(connectTimeoutSeconds * microsPerSecond)
(try (Socket.connect sock (Socket.addrAddress addr)))
case mConnect of
Nothing -> do
Socket.close sock
pure (Left (ConnectTimeout host port))
Just (Left (e :: SomeException)) -> do
Socket.close sock
pure (Left (ConnectFailed host port (show e)))
Just (Right ()) ->
pure (Right sock)
sendUpgradeRequest :: Socket -> Request -> IO ()
sendUpgradeRequest sock req = do
let method = requestMethod req
path = rawPathInfo req <> rawQueryString req
headers = requestHeaders req
requestLine = method <> " " <> path <> " HTTP/1.1\r\n"
sendUpgradeRequest sock req =
let method = requestMethod req
path = rawPathInfo req <> rawQueryString req
headers = requestHeaders req
requestLine = method <> requestPathSeparator <> path <> httpVersionAndCrlf
headerLines = BS.concat
[ original name <> ": " <> value <> "\r\n"
[ original name <> httpFieldSeparator <> value <> httpHeaderLineEnd
| (name, value) <- headers
]
fullRequest = requestLine <> headerLines <> "\r\n"
SocketBS.sendAll sock fullRequest
fullRequest = requestLine <> headerLines <> httpHeaderLineEnd
in SocketBS.sendAll sock fullRequest
receiveUpgradeResponse :: Socket -> IO ByteString
receiveUpgradeResponse sock = do
chunk <- SocketBS.recv sock 4096
if "\r\n\r\n" `BS.isInfixOf` chunk
then return chunk
else do
rest <- receiveUpgradeResponse sock
return $ chunk <> rest
receiveUpgradeResponse sock = go BS.empty
where
go !acc
| upgradeTerminator `BS.isInfixOf` acc = pure acc
| BS.length acc >= maxUpgradeHeaderBytes = pure acc
| otherwise = do
chunk <- SocketBS.recv sock upgradeRecvChunkBytes
if BS.null chunk
then pure acc
else go (acc <> chunk)
parseUpgradeStatus :: ByteString -> Maybe Int
parseUpgradeStatus response =
case BS8.words (head $ BS8.lines response) of
(_:codeBS:_) ->
case reads (BS8.unpack codeBS) of
[(code, "")] -> Just code
_ -> Nothing
parseUpgradeStatus response = case BS8.lines response of
[] -> Nothing
(firstLine : _) -> case BS8.words firstLine of
(_ : codeBS : _) -> case reads (BS8.unpack codeBS) of
[(code, "")] -> Just code
_ -> Nothing
_ -> Nothing

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
@ -173,4 +170,4 @@ wafMiddleware rsVar app req respond = do
[ ("Content-Type", "text/plain; charset=utf-8")
, (wafResponseHeader, "blocked score=" <> BC.pack (show score))
]
"403 Forbidden request blocked by Aenebris WAF"
"403 Forbidden - request blocked by Aenebris WAF"

View File

@ -72,13 +72,16 @@ data Target
| TargetUserAgent
deriving (Eq, Show)
newtype CompiledRegex = CompiledRegex { unCompiledRegex :: Regex }
data CompiledRegex = CompiledRegex
{ unCompiledRegex :: !Regex
, compiledRegexPattern :: !ByteString
}
instance Show CompiledRegex where
show _ = "<CompiledRegex>"
show r = "<CompiledRegex " ++ show (compiledRegexPattern r) ++ ">"
instance Eq CompiledRegex where
_ == _ = False
a == b = compiledRegexPattern a == compiledRegexPattern b
data Operator
= OpRegex !CompiledRegex
@ -115,13 +118,13 @@ compileRegex :: ByteString -> Either String CompiledRegex
compileRegex pat =
case compile compOpts execOpts pat of
Left err -> Left err
Right r -> Right (CompiledRegex r)
Right r -> Right (CompiledRegex r pat)
where
compOpts = TDFA.defaultCompOpt { TDFA.caseSensitive = False }
execOpts = TDFA.defaultExecOpt
runRegex :: CompiledRegex -> ByteString -> Bool
runRegex (CompiledRegex r) input =
case execute r input of
runRegex cr input =
case execute (unCompiledRegex cr) input of
Right (Just _) -> True
_ -> False

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
@ -227,6 +231,7 @@ import Aenebris.ML.Loader
( ParseError(..)
, parseEnsemble
)
import Aenebris.Net.IP (sockAddrToIPBytes)
import Aenebris.Middleware.Redirect (httpsRedirect, httpsRedirectWithPort)
import Aenebris.Middleware.Security
( addSecurityHeaders
@ -262,6 +267,7 @@ import Control.Concurrent.STM
( atomically
, modifyTVar'
, newTVarIO
, readTVar
, readTVarIO
)
import qualified Data.ByteString as BS
@ -375,6 +381,7 @@ main = hspec $ do
wafSpec
honeypotSpec
geoSpec
netIpSpec
mlFeaturesSpec
mlModelSpec
mlLoaderSpec
@ -477,31 +484,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 +548,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 +778,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
@ -817,6 +844,16 @@ wafSpec = describe "WAF" $ do
severityScore SevWarning `shouldBe` 3
severityScore SevNotice `shouldBe` 2
it "Eq CompiledRegex is reflexive (x == x)" $
case compileRegex "abc" of
Right r -> r `shouldBe` r
Left err -> expectationFailure err
it "Eq CompiledRegex distinguishes different patterns" $
case (compileRegex "abc", compileRegex "def") of
(Right r1, Right r2) -> (r1 == r2) `shouldBe` False
_ -> expectationFailure "expected both to compile"
it "default ruleset includes rules" $
length (rsRules defaultRuleSet) `shouldSatisfy` (> 0)
@ -1848,6 +1885,27 @@ mlModelSpec = describe "ML.Model" $ do
validateTree 20 bad `shouldSatisfy`
(\r -> case r of { Left _ -> True; Right _ -> False })
netIpSpec :: Spec
netIpSpec = describe "Net.IP" $ do
it "renders ipv4 sockaddr in dotted decimal" $
sockAddrToIPBytes (ipv4Addr (10, 0, 0, 1) 1234) `shouldBe` "10.0.0.1"
it "renders ipv4 loopback" $
sockAddrToIPBytes (ipv4Addr (127, 0, 0, 1) 8080) `shouldBe` "127.0.0.1"
it "renders unix sockaddr with prefix" $
sockAddrToIPBytes (SockAddrUnix "/tmp/sock") `shouldBe` "unix:/tmp/sock"
it "renders ipv6 sockaddr separated by colons (eight 16-bit groups)" $ do
let addr = SockAddrInet6
0
0
(tupleToHostAddress6 (0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))
0
result = sockAddrToIPBytes addr
BS.length result `shouldSatisfy` (> 0)
BC.count ':' result `shouldBe` 7
mlLoaderModel :: T.Text
mlLoaderModel = T.unlines
[ "tree"
@ -2074,6 +2132,16 @@ mlLoaderSpec = describe "ML.Loader" $ do
(T.replace "leaf_value=0.5\n" "leaf_value=0.5\nbogus=1\n" mlLoaderModel))
`shouldSatisfy` isLeft
it "rejects num_leaves above maxNumLeaves" $
parseEnsemble
(TE.encodeUtf8 (T.replace "num_leaves=1" "num_leaves=999999" mlLoaderModel))
`shouldSatisfy` parseFailsAt "num_leaves"
it "rejects num_leaves of 0" $
parseEnsemble
(TE.encodeUtf8 (T.replace "num_leaves=1" "num_leaves=0" mlLoaderModel))
`shouldSatisfy` parseFailsAt "num_leaves"
describe "feature_names containing '='" $
it "accepts feature_names with '=' in a name" $
parseEnsemble