Merge pull request #199 from CarterPerez-dev/chore/haskell-reverse-proxy-finish
Chore/haskell reverse proxy finish
This commit is contained in:
commit
09542d3a83
|
|
@ -59,8 +59,22 @@ class SurrealDBManager:
|
||||||
settings.SURREAL_DATABASE,
|
settings.SURREAL_DATABASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await self._init_schema()
|
||||||
|
|
||||||
self._connected = True
|
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:
|
async def disconnect(self) -> None:
|
||||||
"""
|
"""
|
||||||
Close SurrealDB connection
|
Close SurrealDB connection
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
surrealdb:
|
surrealdb:
|
||||||
image: surrealdb/surrealdb:latest
|
image: surrealdb/surrealdb:v3.0.5
|
||||||
container_name: chat-surrealdb
|
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:
|
environment:
|
||||||
- SURREAL_USER=root
|
- SURREAL_USER=root
|
||||||
- SURREAL_PASS=${SURREAL_PASSWORD}
|
- SURREAL_PASS=${SURREAL_PASSWORD}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
# ©AngelaMos | 2025
|
# ©AngelaMos | 2026
|
||||||
# Development Vite Dockerfile
|
# vite.docker
|
||||||
# HMR dev server, volume mounts for code
|
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
|
||||||
RUN npm ci
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
# ©AngelaMos | 2025
|
# ©AngelaMos | 2026
|
||||||
# Production Vite Dockerfile
|
# vite.docker
|
||||||
# Multi-stage: build with node, serve with nginx
|
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
|
||||||
RUN npm ci
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_dev_data:/var/lib/postgresql/data
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
- "${POSTGRES_HOST_PORT:-54332}:5432"
|
||||||
networks:
|
networks:
|
||||||
- chat_network_dev
|
- chat_network_dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -23,17 +23,17 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
surrealdb:
|
surrealdb:
|
||||||
image: surrealdb/surrealdb:latest
|
image: surrealdb/surrealdb:v3.0.5
|
||||||
container_name: chat-surrealdb-dev
|
container_name: chat-surrealdb-dev
|
||||||
user: "0:0"
|
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:
|
environment:
|
||||||
- SURREAL_USER=root
|
- SURREAL_USER=root
|
||||||
- SURREAL_PASS=${SURREAL_PASSWORD}
|
- SURREAL_PASS=${SURREAL_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- surreal_dev_data:/data
|
- surreal_dev_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "${SURREAL_HOST_PORT:-8001}:8000"
|
- "${SURREAL_HOST_PORT:-7781}:8000"
|
||||||
networks:
|
networks:
|
||||||
- chat_network_dev
|
- chat_network_dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -55,7 +55,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- redis_dev_data:/data
|
- redis_dev_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
- "${REDIS_HOST_PORT:-43434}:6379"
|
||||||
networks:
|
networks:
|
||||||
- chat_network_dev
|
- chat_network_dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -82,7 +82,7 @@ services:
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
RP_ID: ${RP_ID:-localhost}
|
RP_ID: ${RP_ID:-localhost}
|
||||||
RP_NAME: ${RP_NAME:-Encrypted P2P Chat}
|
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}
|
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
|
@ -98,7 +98,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- chat_network_dev
|
- chat_network_dev
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_HOST_PORT:-8000}:8000"
|
- "${BACKEND_HOST_PORT:-30200}:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|
@ -118,14 +118,14 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- chat_network_dev
|
- chat_network_dev
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_DEV_PORT:-5173}:5173"
|
- "${FRONTEND_DEV_PORT:-34343}:5173"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: chat-nginx-dev
|
container_name: chat-nginx-dev
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_HTTP_PORT:-80}:80"
|
- "${NGINX_HTTP_PORT:-3234}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf/nginx/dev.nginx:/etc/nginx/nginx.conf:ro
|
- ./conf/nginx/dev.nginx:/etc/nginx/nginx.conf:ro
|
||||||
- ./conf/nginx/http.conf:/etc/nginx/http.conf:ro
|
- ./conf/nginx/http.conf:/etc/nginx/http.conf:ro
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ license: MIT
|
||||||
license-file: LICENSE
|
license-file: LICENSE
|
||||||
author: Carter Perez
|
author: Carter Perez
|
||||||
maintainer: support@certgames.com
|
maintainer: support@certgames.com
|
||||||
copyright: 2025 Carter Perez
|
copyright: 2026 AngelaMos
|
||||||
category: Network, Security, Web
|
category: Network, Security, Web
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
extra-source-files: README.md
|
extra-source-files: README.md
|
||||||
|
|
@ -38,6 +38,7 @@ library
|
||||||
, Aenebris.WAF.Engine
|
, Aenebris.WAF.Engine
|
||||||
, Aenebris.Honeypot
|
, Aenebris.Honeypot
|
||||||
, Aenebris.Geo
|
, Aenebris.Geo
|
||||||
|
, Aenebris.Net.IP
|
||||||
, Aenebris.ML.Features
|
, Aenebris.ML.Features
|
||||||
, Aenebris.ML.Model
|
, Aenebris.ML.Model
|
||||||
, Aenebris.ML.Loader
|
, Aenebris.ML.Loader
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,60 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Main.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module Main (main) where
|
module Main (main) where
|
||||||
|
|
||||||
import Aenebris.Config
|
import Aenebris.Config
|
||||||
|
import Aenebris.Connection
|
||||||
|
( defaultTimeoutConfig
|
||||||
|
, microsPerSecond
|
||||||
|
, tcUpstreamReadSeconds
|
||||||
|
)
|
||||||
import Aenebris.Proxy
|
import Aenebris.Proxy
|
||||||
import Network.HTTP.Client (newManager, defaultManagerSettings)
|
import Network.HTTP.Client
|
||||||
|
( ManagerSettings(..)
|
||||||
|
, defaultManagerSettings
|
||||||
|
, newManager
|
||||||
|
, responseTimeoutMicro
|
||||||
|
)
|
||||||
import System.Environment (getArgs)
|
import System.Environment (getArgs)
|
||||||
import System.Exit (exitFailure)
|
import System.Exit (exitFailure)
|
||||||
import System.IO (hPutStrLn, stderr)
|
import System.IO (hPutStrLn, stderr)
|
||||||
|
|
||||||
|
defaultConfigPath :: FilePath
|
||||||
|
defaultConfigPath = "config.yaml"
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
args <- getArgs
|
args <- getArgs
|
||||||
|
|
||||||
-- Get config file path from args or use default
|
|
||||||
let configPath = case args of
|
let configPath = case args of
|
||||||
(path:_) -> path
|
(path:_) -> path
|
||||||
[] -> "config.yaml"
|
[] -> defaultConfigPath
|
||||||
|
|
||||||
putStrLn $ "Loading configuration from: " ++ configPath
|
putStrLn $ "Loading configuration from: " ++ configPath
|
||||||
|
|
||||||
result <- loadConfig configPath
|
result <- loadConfig configPath
|
||||||
case result of
|
case result of
|
||||||
Left err -> do
|
Left err -> do
|
||||||
hPutStrLn stderr $ "ERROR: Failed to load configuration"
|
hPutStrLn stderr "ERROR: Failed to load configuration"
|
||||||
hPutStrLn stderr err
|
hPutStrLn stderr err
|
||||||
exitFailure
|
exitFailure
|
||||||
|
|
||||||
Right config -> do
|
Right config -> case validateConfig config of
|
||||||
case validateConfig config of
|
Left err -> do
|
||||||
Left err -> do
|
hPutStrLn stderr "ERROR: Invalid configuration"
|
||||||
hPutStrLn stderr $ "ERROR: Invalid configuration"
|
hPutStrLn stderr err
|
||||||
hPutStrLn stderr err
|
exitFailure
|
||||||
exitFailure
|
|
||||||
|
|
||||||
Right () -> do
|
Right () -> do
|
||||||
putStrLn "Configuration loaded and validated successfully"
|
putStrLn "Configuration loaded and validated successfully"
|
||||||
|
let upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig
|
||||||
-- Create HTTP client manager with connection pooling
|
* microsPerSecond
|
||||||
manager <- newManager defaultManagerSettings
|
managerSettings = defaultManagerSettings
|
||||||
|
{ managerResponseTimeout = responseTimeoutMicro upstreamMicros
|
||||||
-- Initialize proxy state (load balancers + health checkers)
|
}
|
||||||
proxyState <- initProxyState config manager
|
manager <- newManager managerSettings
|
||||||
|
proxyState <- initProxyState config manager
|
||||||
-- Start the proxy
|
startProxy proxyState
|
||||||
startProxy proxyState
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Backend.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module Aenebris.Backend
|
module Aenebris.Backend
|
||||||
|
|
@ -22,126 +26,125 @@ import Control.Monad (when)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Data.Time.Clock (UTCTime)
|
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
|
data BackendState
|
||||||
= Healthy
|
= Healthy
|
||||||
| Unhealthy
|
| Unhealthy
|
||||||
| Recovering
|
| Recovering
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
-- | Runtime backend state wrapping config Server
|
|
||||||
data RuntimeBackend = RuntimeBackend
|
data RuntimeBackend = RuntimeBackend
|
||||||
{ rbServerId :: Int -- Unique identifier
|
{ rbServerId :: !Int
|
||||||
, rbHost :: Text
|
, rbHost :: !Text
|
||||||
, rbWeight :: Int
|
, rbWeight :: !Int
|
||||||
-- Runtime state (STM)
|
, rbActiveConnections :: !(TVar Int)
|
||||||
, rbActiveConnections :: TVar Int
|
, rbCurrentWeight :: !(TVar Int)
|
||||||
, rbCurrentWeight :: TVar Int
|
, rbHealthState :: !(TVar BackendState)
|
||||||
, rbHealthState :: TVar BackendState
|
, rbConsecutiveFailures :: !(TVar Int)
|
||||||
, rbConsecutiveFailures :: TVar Int
|
, rbConsecutiveSuccesses :: !(TVar Int)
|
||||||
, rbConsecutiveSuccesses :: TVar Int
|
, rbLastHealthCheck :: !(TVar (Maybe UTCTime))
|
||||||
, rbLastHealthCheck :: TVar (Maybe UTCTime)
|
, rbTotalRequests :: !(TVar Int)
|
||||||
, rbTotalRequests :: TVar Int -- For metrics
|
, rbTotalFailures :: !(TVar Int)
|
||||||
, rbTotalFailures :: TVar Int -- For metrics
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instance Show RuntimeBackend where
|
instance Show RuntimeBackend where
|
||||||
show rb = "RuntimeBackend {id=" ++ show (rbServerId rb) ++
|
show rb = "RuntimeBackend {id="
|
||||||
", host=" ++ show (rbHost rb) ++ "}"
|
++ show (rbServerId rb)
|
||||||
|
++ ", host="
|
||||||
|
++ show (rbHost rb)
|
||||||
|
++ ", weight="
|
||||||
|
++ show (rbWeight rb)
|
||||||
|
++ "}"
|
||||||
|
|
||||||
instance Eq RuntimeBackend where
|
instance Eq RuntimeBackend where
|
||||||
rb1 == rb2 = rbServerId rb1 == rbServerId rb2
|
rb1 == rb2 = rbServerId rb1 == rbServerId rb2
|
||||||
|
|
||||||
-- | Runtime backend from config Server
|
|
||||||
createRuntimeBackend :: Int -> Server -> IO RuntimeBackend
|
createRuntimeBackend :: Int -> Server -> IO RuntimeBackend
|
||||||
createRuntimeBackend serverId Server{..} = do
|
createRuntimeBackend serverId Server{..} = atomically $
|
||||||
atomically $ RuntimeBackend serverId serverHost serverWeight
|
RuntimeBackend serverId serverHost serverWeight
|
||||||
<$> newTVar 0 -- activeConnections
|
<$> newTVar initialActiveConnections
|
||||||
<*> newTVar 0 -- currentWeight (for smooth WRR)
|
<*> newTVar initialCurrentWeight
|
||||||
<*> newTVar Healthy -- healthState
|
<*> newTVar Healthy
|
||||||
<*> newTVar 0 -- consecutiveFailures
|
<*> newTVar initialFailureCount
|
||||||
<*> newTVar 0 -- consecutiveSuccesses
|
<*> newTVar initialSuccessCount
|
||||||
<*> newTVar Nothing -- lastHealthCheck
|
<*> newTVar Nothing
|
||||||
<*> newTVar 0 -- totalRequests
|
<*> newTVar initialMetricCount
|
||||||
<*> newTVar 0 -- totalFailures
|
<*> newTVar initialMetricCount
|
||||||
|
|
||||||
-- | Check if backend is healthy
|
|
||||||
isHealthy :: RuntimeBackend -> STM Bool
|
isHealthy :: RuntimeBackend -> STM Bool
|
||||||
isHealthy rb = (== Healthy) <$> readTVar (rbHealthState rb)
|
isHealthy rb = (== Healthy) <$> readTVar (rbHealthState rb)
|
||||||
|
|
||||||
-- | Track a connection (increment on start, decrement on end)
|
|
||||||
trackConnection :: RuntimeBackend -> IO a -> IO a
|
trackConnection :: RuntimeBackend -> IO a -> IO a
|
||||||
trackConnection rb action =
|
trackConnection rb action =
|
||||||
bracket_
|
bracket_
|
||||||
(atomically $ do
|
(atomically $ do
|
||||||
modifyTVar' (rbActiveConnections rb) (+1)
|
modifyTVar' (rbActiveConnections rb) (+ 1)
|
||||||
modifyTVar' (rbTotalRequests rb) (+1))
|
modifyTVar' (rbTotalRequests rb) (+ 1))
|
||||||
(atomically $ modifyTVar' (rbActiveConnections rb) (subtract 1))
|
(atomically $ modifyTVar' (rbActiveConnections rb) (subtract 1))
|
||||||
action
|
action
|
||||||
|
|
||||||
-- | Get current connection count
|
|
||||||
getConnectionCount :: RuntimeBackend -> STM Int
|
getConnectionCount :: RuntimeBackend -> STM Int
|
||||||
getConnectionCount rb = readTVar (rbActiveConnections rb)
|
getConnectionCount rb = readTVar (rbActiveConnections rb)
|
||||||
|
|
||||||
-- | Get current weight (for smooth weighted RR)
|
|
||||||
getCurrentWeight :: RuntimeBackend -> STM Int
|
getCurrentWeight :: RuntimeBackend -> STM Int
|
||||||
getCurrentWeight rb = readTVar (rbCurrentWeight rb)
|
getCurrentWeight rb = readTVar (rbCurrentWeight rb)
|
||||||
|
|
||||||
-- | State transition: mark as unhealthy
|
|
||||||
transitionToUnhealthy :: RuntimeBackend -> STM ()
|
transitionToUnhealthy :: RuntimeBackend -> STM ()
|
||||||
transitionToUnhealthy rb = do
|
transitionToUnhealthy rb = do
|
||||||
writeTVar (rbHealthState rb) Unhealthy
|
writeTVar (rbHealthState rb) Unhealthy
|
||||||
writeTVar (rbConsecutiveFailures rb) 0
|
writeTVar (rbConsecutiveFailures rb) initialFailureCount
|
||||||
writeTVar (rbConsecutiveSuccesses rb) 0
|
writeTVar (rbConsecutiveSuccesses rb) initialSuccessCount
|
||||||
|
|
||||||
-- | State transition: start recovering
|
|
||||||
transitionToRecovering :: RuntimeBackend -> STM ()
|
transitionToRecovering :: RuntimeBackend -> STM ()
|
||||||
transitionToRecovering rb = do
|
transitionToRecovering rb = do
|
||||||
writeTVar (rbHealthState rb) Recovering
|
writeTVar (rbHealthState rb) Recovering
|
||||||
writeTVar (rbConsecutiveSuccesses rb) 1
|
writeTVar (rbConsecutiveSuccesses rb) 1
|
||||||
|
|
||||||
-- | State transition: mark as healthy
|
|
||||||
transitionToHealthy :: RuntimeBackend -> STM ()
|
transitionToHealthy :: RuntimeBackend -> STM ()
|
||||||
transitionToHealthy rb = do
|
transitionToHealthy rb = do
|
||||||
writeTVar (rbHealthState rb) Healthy
|
writeTVar (rbHealthState rb) Healthy
|
||||||
writeTVar (rbConsecutiveFailures rb) 0
|
writeTVar (rbConsecutiveFailures rb) initialFailureCount
|
||||||
writeTVar (rbConsecutiveSuccesses rb) 0
|
writeTVar (rbConsecutiveSuccesses rb) initialSuccessCount
|
||||||
|
|
||||||
-- | Record a health check failure
|
|
||||||
recordFailure :: RuntimeBackend -> Int -> STM ()
|
recordFailure :: RuntimeBackend -> Int -> STM ()
|
||||||
recordFailure rb maxFailures = do
|
recordFailure rb maxFailures = do
|
||||||
|
modifyTVar' (rbTotalFailures rb) (+ 1)
|
||||||
state <- readTVar (rbHealthState rb)
|
state <- readTVar (rbHealthState rb)
|
||||||
failures <- readTVar (rbConsecutiveFailures rb)
|
failures <- readTVar (rbConsecutiveFailures rb)
|
||||||
|
|
||||||
case state of
|
case state of
|
||||||
Healthy -> do
|
Healthy -> do
|
||||||
let newFailures = failures + 1
|
let newFailures = failures + 1
|
||||||
writeTVar (rbConsecutiveFailures rb) newFailures
|
writeTVar (rbConsecutiveFailures rb) newFailures
|
||||||
when (newFailures >= maxFailures) $
|
when (newFailures >= maxFailures) $
|
||||||
transitionToUnhealthy rb
|
transitionToUnhealthy rb
|
||||||
|
Recovering ->
|
||||||
Recovering -> do
|
|
||||||
-- Failed during recovery, back to unhealthy
|
|
||||||
transitionToUnhealthy rb
|
transitionToUnhealthy rb
|
||||||
|
|
||||||
Unhealthy ->
|
Unhealthy ->
|
||||||
-- Already unhealthy, just record it
|
pure ()
|
||||||
modifyTVar' (rbTotalFailures rb) (+1)
|
|
||||||
|
|
||||||
-- | Record a health check success
|
|
||||||
recordSuccess :: RuntimeBackend -> Int -> STM ()
|
recordSuccess :: RuntimeBackend -> Int -> STM ()
|
||||||
recordSuccess rb recoveryAttempts = do
|
recordSuccess rb recoveryAttempts = do
|
||||||
state <- readTVar (rbHealthState rb)
|
state <- readTVar (rbHealthState rb)
|
||||||
successes <- readTVar (rbConsecutiveSuccesses rb)
|
successes <- readTVar (rbConsecutiveSuccesses rb)
|
||||||
|
|
||||||
case state of
|
case state of
|
||||||
Healthy ->
|
Healthy ->
|
||||||
-- Reset failure counter
|
writeTVar (rbConsecutiveFailures rb) initialFailureCount
|
||||||
writeTVar (rbConsecutiveFailures rb) 0
|
|
||||||
|
|
||||||
Unhealthy ->
|
Unhealthy ->
|
||||||
-- First success, transition to recovering
|
|
||||||
transitionToRecovering rb
|
transitionToRecovering rb
|
||||||
|
|
||||||
Recovering -> do
|
Recovering -> do
|
||||||
let newSuccesses = successes + 1
|
let newSuccesses = successes + 1
|
||||||
writeTVar (rbConsecutiveSuccesses rb) newSuccesses
|
writeTVar (rbConsecutiveSuccesses rb) newSuccesses
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Config.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE DeriveGeneric #-}
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
|
@ -20,51 +24,63 @@ module Aenebris.Config
|
||||||
import Aenebris.Honeypot (HoneypotConfigYaml)
|
import Aenebris.Honeypot (HoneypotConfigYaml)
|
||||||
import Aenebris.Geo (GeoConfigYaml)
|
import Aenebris.Geo (GeoConfigYaml)
|
||||||
|
|
||||||
import Control.Monad (when, forM_)
|
import Control.Monad (forM_, when)
|
||||||
import Data.Aeson
|
import Data.Aeson
|
||||||
|
import Data.List (nub)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Yaml (decodeFileEither)
|
import Data.Yaml (decodeFileEither)
|
||||||
import GHC.Generics
|
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
|
data Config = Config
|
||||||
{ configVersion :: Int
|
{ configVersion :: !Int
|
||||||
, configListen :: [ListenConfig]
|
, configListen :: ![ListenConfig]
|
||||||
, configUpstreams :: [Upstream]
|
, configUpstreams :: ![Upstream]
|
||||||
, configRoutes :: [Route]
|
, configRoutes :: ![Route]
|
||||||
, configRateLimit :: Maybe Text
|
, configRateLimit :: !(Maybe Text)
|
||||||
, configDDoS :: Maybe DDoSConfig
|
, configDDoS :: !(Maybe DDoSConfig)
|
||||||
, configHoneypot :: Maybe HoneypotConfigYaml
|
, configHoneypot :: !(Maybe HoneypotConfigYaml)
|
||||||
, configGeo :: Maybe GeoConfigYaml
|
, configGeo :: !(Maybe GeoConfigYaml)
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON Config where
|
instance FromJSON Config where
|
||||||
parseJSON = withObject "Config" $ \v -> Config
|
parseJSON = withObject "Config" $ \v -> Config
|
||||||
<$> v .: "version"
|
<$> v .: "version"
|
||||||
<*> v .: "listen"
|
<*> v .: "listen"
|
||||||
<*> v .: "upstreams"
|
<*> v .: "upstreams"
|
||||||
<*> v .: "routes"
|
<*> v .: "routes"
|
||||||
<*> v .:? "rate_limit"
|
<*> v .:? "rate_limit"
|
||||||
<*> v .:? "ddos"
|
<*> v .:? "ddos"
|
||||||
<*> v .:? "honeypot"
|
<*> v .:? "honeypot"
|
||||||
<*> v .:? "geo"
|
<*> v .:? "geo"
|
||||||
|
|
||||||
data DDoSConfig = DDoSConfig
|
data DDoSConfig = DDoSConfig
|
||||||
{ ddosEarlyDataReject :: Bool
|
{ ddosEarlyDataReject :: !Bool
|
||||||
, ddosPerIPConnections :: Maybe Int
|
, ddosPerIPConnections :: !(Maybe Int)
|
||||||
, ddosMemoryShedBytes :: Maybe Integer
|
, ddosMemoryShedBytes :: !(Maybe Integer)
|
||||||
, ddosMemoryShedHighWater :: Maybe Double
|
, ddosMemoryShedHighWater :: !(Maybe Double)
|
||||||
, ddosMaxConcurrentStreams :: Maybe Int
|
, ddosMaxConcurrentStreams :: !(Maybe Int)
|
||||||
, ddosMaxHeaderBytes :: Maybe Int
|
, ddosMaxHeaderBytes :: !(Maybe Int)
|
||||||
, ddosSlowlorisSeconds :: Maybe Int
|
, ddosSlowlorisSeconds :: !(Maybe Int)
|
||||||
, ddosJailCooldownSeconds :: Maybe Int
|
, ddosJailCooldownSeconds :: !(Maybe Int)
|
||||||
, ddosReusePort :: Bool
|
, ddosReusePort :: !Bool
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON DDoSConfig where
|
instance FromJSON DDoSConfig where
|
||||||
parseJSON = withObject "DDoSConfig" $ \v -> DDoSConfig
|
parseJSON = withObject "DDoSConfig" $ \v -> DDoSConfig
|
||||||
<$> v .:? "early_data_reject" .!= True
|
<$> v .:? "early_data_reject" .!= True
|
||||||
<*> v .:? "per_ip_connections"
|
<*> v .:? "per_ip_connections"
|
||||||
<*> v .:? "memory_shed_bytes"
|
<*> v .:? "memory_shed_bytes"
|
||||||
<*> v .:? "memory_shed_high_water"
|
<*> v .:? "memory_shed_high_water"
|
||||||
|
|
@ -72,41 +88,39 @@ instance FromJSON DDoSConfig where
|
||||||
<*> v .:? "max_header_bytes"
|
<*> v .:? "max_header_bytes"
|
||||||
<*> v .:? "slowloris_seconds"
|
<*> v .:? "slowloris_seconds"
|
||||||
<*> v .:? "jail_cooldown_seconds"
|
<*> v .:? "jail_cooldown_seconds"
|
||||||
<*> v .:? "reuse_port" .!= False
|
<*> v .:? "reuse_port" .!= False
|
||||||
|
|
||||||
defaultDDoSConfig :: DDoSConfig
|
defaultDDoSConfig :: DDoSConfig
|
||||||
defaultDDoSConfig = DDoSConfig
|
defaultDDoSConfig = DDoSConfig
|
||||||
{ ddosEarlyDataReject = True
|
{ ddosEarlyDataReject = True
|
||||||
, ddosPerIPConnections = Nothing
|
, ddosPerIPConnections = Nothing
|
||||||
, ddosMemoryShedBytes = Nothing
|
, ddosMemoryShedBytes = Nothing
|
||||||
, ddosMemoryShedHighWater = Nothing
|
, ddosMemoryShedHighWater = Nothing
|
||||||
, ddosMaxConcurrentStreams = Nothing
|
, ddosMaxConcurrentStreams = Nothing
|
||||||
, ddosMaxHeaderBytes = Nothing
|
, ddosMaxHeaderBytes = Nothing
|
||||||
, ddosSlowlorisSeconds = Nothing
|
, ddosSlowlorisSeconds = Nothing
|
||||||
, ddosJailCooldownSeconds = Nothing
|
, ddosJailCooldownSeconds = Nothing
|
||||||
, ddosReusePort = False
|
, ddosReusePort = False
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Listen port configuration
|
|
||||||
data ListenConfig = ListenConfig
|
data ListenConfig = ListenConfig
|
||||||
{ listenPort :: Int
|
{ listenPort :: !Int
|
||||||
, listenTLS :: Maybe TLSConfig
|
, listenTLS :: !(Maybe TLSConfig)
|
||||||
, listenRedirectHTTPS :: Maybe Bool -- Redirect HTTP to HTTPS?
|
, listenRedirectHTTPS :: !(Maybe Bool)
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON ListenConfig where
|
instance FromJSON ListenConfig where
|
||||||
parseJSON = withObject "ListenConfig" $ \v -> ListenConfig
|
parseJSON = withObject "ListenConfig" $ \v -> ListenConfig
|
||||||
<$> v .: "port"
|
<$> v .: "port"
|
||||||
<*> v .:? "tls"
|
<*> v .:? "tls"
|
||||||
<*> v .:? "redirect_https"
|
<*> v .:? "redirect_https"
|
||||||
|
|
||||||
-- | TLS/SSL configuration (supports both single cert and SNI)
|
|
||||||
data TLSConfig = TLSConfig
|
data TLSConfig = TLSConfig
|
||||||
{ tlsCert :: Maybe FilePath -- Single cert (if not using SNI)
|
{ tlsCert :: !(Maybe FilePath)
|
||||||
, tlsKey :: Maybe FilePath -- Single key (if not using SNI)
|
, tlsKey :: !(Maybe FilePath)
|
||||||
, tlsSNI :: Maybe [SNIDomain] -- SNI domains (multiple certs)
|
, tlsSNI :: !(Maybe [SNIDomain])
|
||||||
, tlsDefaultCert :: Maybe FilePath -- Default cert for SNI
|
, tlsDefaultCert :: !(Maybe FilePath)
|
||||||
, tlsDefaultKey :: Maybe FilePath -- Default key for SNI
|
, tlsDefaultKey :: !(Maybe FilePath)
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON TLSConfig where
|
instance FromJSON TLSConfig where
|
||||||
|
|
@ -117,11 +131,10 @@ instance FromJSON TLSConfig where
|
||||||
<*> v .:? "default_cert"
|
<*> v .:? "default_cert"
|
||||||
<*> v .:? "default_key"
|
<*> v .:? "default_key"
|
||||||
|
|
||||||
-- | SNI domain configuration
|
|
||||||
data SNIDomain = SNIDomain
|
data SNIDomain = SNIDomain
|
||||||
{ sniDomain :: Text
|
{ sniDomain :: !Text
|
||||||
, sniCert :: FilePath
|
, sniCert :: !FilePath
|
||||||
, sniKey :: FilePath
|
, sniKey :: !FilePath
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON SNIDomain where
|
instance FromJSON SNIDomain where
|
||||||
|
|
@ -130,23 +143,21 @@ instance FromJSON SNIDomain where
|
||||||
<*> v .: "cert"
|
<*> v .: "cert"
|
||||||
<*> v .: "key"
|
<*> v .: "key"
|
||||||
|
|
||||||
-- | Upstream backend definition
|
|
||||||
data Upstream = Upstream
|
data Upstream = Upstream
|
||||||
{ upstreamName :: Text
|
{ upstreamName :: !Text
|
||||||
, upstreamServers :: [Server]
|
, upstreamServers :: ![Server]
|
||||||
, upstreamHealthCheck :: Maybe HealthCheck
|
, upstreamHealthCheck :: !(Maybe HealthCheck)
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON Upstream where
|
instance FromJSON Upstream where
|
||||||
parseJSON = withObject "Upstream" $ \v -> Upstream
|
parseJSON = withObject "Upstream" $ \v -> Upstream
|
||||||
<$> v .: "name"
|
<$> v .: "name"
|
||||||
<*> v .: "servers"
|
<*> v .: "servers"
|
||||||
<*> v .:? "health_check"
|
<*> v .:? "health_check"
|
||||||
|
|
||||||
-- | Backend server with weight for load balancing
|
|
||||||
data Server = Server
|
data Server = Server
|
||||||
{ serverHost :: Text
|
{ serverHost :: !Text
|
||||||
, serverWeight :: Int
|
, serverWeight :: !Int
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON Server where
|
instance FromJSON Server where
|
||||||
|
|
@ -154,10 +165,9 @@ instance FromJSON Server where
|
||||||
<$> v .: "host"
|
<$> v .: "host"
|
||||||
<*> v .: "weight"
|
<*> v .: "weight"
|
||||||
|
|
||||||
-- | Health check configuration
|
|
||||||
data HealthCheck = HealthCheck
|
data HealthCheck = HealthCheck
|
||||||
{ healthCheckPath :: Text
|
{ healthCheckPath :: !Text
|
||||||
, healthCheckInterval :: Text -- e.g., "10s"
|
, healthCheckInterval :: !Text
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON HealthCheck where
|
instance FromJSON HealthCheck where
|
||||||
|
|
@ -165,10 +175,9 @@ instance FromJSON HealthCheck where
|
||||||
<$> v .: "path"
|
<$> v .: "path"
|
||||||
<*> v .: "interval"
|
<*> v .: "interval"
|
||||||
|
|
||||||
-- | Route definition (virtual host + paths)
|
|
||||||
data Route = Route
|
data Route = Route
|
||||||
{ routeHost :: Text
|
{ routeHost :: !Text
|
||||||
, routePaths :: [PathRoute]
|
, routePaths :: ![PathRoute]
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON Route where
|
instance FromJSON Route where
|
||||||
|
|
@ -176,122 +185,99 @@ instance FromJSON Route where
|
||||||
<$> v .: "host"
|
<$> v .: "host"
|
||||||
<*> v .: "paths"
|
<*> v .: "paths"
|
||||||
|
|
||||||
-- | Path-based routing rule
|
|
||||||
data PathRoute = PathRoute
|
data PathRoute = PathRoute
|
||||||
{ pathRoutePath :: Text
|
{ pathRoutePath :: !Text
|
||||||
, pathRouteUpstream :: Text
|
, pathRouteUpstream :: !Text
|
||||||
, pathRouteRateLimit :: Maybe Text -- e.g., "100/minute"
|
, pathRouteRateLimit :: !(Maybe Text)
|
||||||
} deriving (Show, Eq, Generic)
|
} deriving (Show, Eq, Generic)
|
||||||
|
|
||||||
instance FromJSON PathRoute where
|
instance FromJSON PathRoute where
|
||||||
parseJSON = withObject "PathRoute" $ \v -> PathRoute
|
parseJSON = withObject "PathRoute" $ \v -> PathRoute
|
||||||
<$> v .: "path"
|
<$> v .: "path"
|
||||||
<*> v .: "upstream"
|
<*> v .: "upstream"
|
||||||
<*> v .:? "rate_limit"
|
<*> v .:? "rate_limit"
|
||||||
|
|
||||||
-- | Load configuration from YAML file
|
|
||||||
loadConfig :: FilePath -> IO (Either String Config)
|
loadConfig :: FilePath -> IO (Either String Config)
|
||||||
loadConfig path = do
|
loadConfig path = do
|
||||||
result <- decodeFileEither path
|
result <- decodeFileEither path
|
||||||
return $ case result of
|
pure $ case result of
|
||||||
Left err -> Left (show err)
|
Left err -> Left (show err)
|
||||||
Right config -> Right config
|
Right config -> Right config
|
||||||
|
|
||||||
-- | Validate configuration for correctness
|
|
||||||
validateConfig :: Config -> Either String ()
|
validateConfig :: Config -> Either String ()
|
||||||
validateConfig config = do
|
validateConfig config = do
|
||||||
-- Check version
|
when (configVersion config /= supportedConfigVersion) $
|
||||||
when (configVersion config /= 1) $
|
Left ("Unsupported config version (expected: "
|
||||||
Left "Unsupported config version (expected: 1)"
|
++ 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"
|
Left "At least one listen port must be specified"
|
||||||
|
|
||||||
-- Check port numbers are valid
|
|
||||||
forM_ (configListen config) $ \listen -> do
|
forM_ (configListen config) $ \listen -> do
|
||||||
let port = listenPort listen
|
let port = listenPort listen
|
||||||
when (port < 1 || port > 65535) $
|
when (port < minPort || port > maxPort) $
|
||||||
Left $ "Invalid port number: " ++ show port
|
Left ("Invalid port number: " ++ show port)
|
||||||
|
|
||||||
-- Validate TLS configuration if present
|
|
||||||
case listenTLS listen of
|
case listenTLS listen of
|
||||||
Nothing -> return ()
|
Nothing -> pure ()
|
||||||
Just tlsConf -> validateTLS tlsConf
|
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"
|
Left "At least one upstream must be specified"
|
||||||
|
|
||||||
-- Check upstream names are unique
|
|
||||||
let upstreamNames = map upstreamName (configUpstreams config)
|
let upstreamNames = map upstreamName (configUpstreams config)
|
||||||
when (length upstreamNames /= length (nubText upstreamNames)) $
|
when (length upstreamNames /= length (nub upstreamNames)) $
|
||||||
Left "Upstream names must be unique"
|
Left "Upstream names must be unique"
|
||||||
|
|
||||||
-- Check each upstream has at least one server
|
|
||||||
forM_ (configUpstreams config) $ \upstream -> do
|
forM_ (configUpstreams config) $ \upstream -> do
|
||||||
when (null $ upstreamServers upstream) $
|
when (null (upstreamServers upstream)) $
|
||||||
Left $ "Upstream '" ++ T.unpack (upstreamName upstream) ++ "' has no servers"
|
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
|
when (null (configRoutes config)) $
|
||||||
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) $
|
|
||||||
Left "At least one route must be specified"
|
Left "At least one route must be specified"
|
||||||
|
|
||||||
-- Validate upstream references in routes
|
|
||||||
forM_ (configRoutes config) $ \route -> do
|
forM_ (configRoutes config) $ \route -> do
|
||||||
when (null $ routePaths route) $
|
when (null (routePaths route)) $
|
||||||
Left $ "Route for host '" ++ T.unpack (routeHost route) ++ "' has no paths"
|
Left ("Route for host '" ++ T.unpack (routeHost route)
|
||||||
|
++ "' has no paths")
|
||||||
forM_ (routePaths route) $ \pathRoute -> do
|
forM_ (routePaths route) $ \pathRoute -> do
|
||||||
let upstreamRef = pathRouteUpstream pathRoute
|
let upstreamRef = pathRouteUpstream pathRoute
|
||||||
when (upstreamRef `notElem` upstreamNames) $
|
when (upstreamRef `notElem` upstreamNames) $
|
||||||
Left $ "Unknown upstream referenced: '" ++ T.unpack upstreamRef ++ "'"
|
Left ("Unknown upstream referenced: '"
|
||||||
|
++ T.unpack upstreamRef ++ "'")
|
||||||
|
|
||||||
return ()
|
pure ()
|
||||||
where
|
|
||||||
-- Helper to remove duplicates from Text list
|
|
||||||
nubText :: [Text] -> [Text]
|
|
||||||
nubText [] = []
|
|
||||||
nubText (x:xs) = x : nubText (filter (/= x) xs)
|
|
||||||
|
|
||||||
-- Validate TLS configuration
|
validateTLS :: TLSConfig -> Either String ()
|
||||||
validateTLS :: TLSConfig -> Either String ()
|
validateTLS tlsConf = do
|
||||||
validateTLS tlsConf = do
|
let hasSingleCert = case (tlsCert tlsConf, tlsKey tlsConf) of
|
||||||
let hasSingleCert = case (tlsCert tlsConf, tlsKey tlsConf) of
|
(Just _, Just _) -> True
|
||||||
(Just _, Just _) -> True
|
_ -> False
|
||||||
(Nothing, Nothing) -> False
|
hasSNI = case (tlsSNI tlsConf, tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of
|
||||||
_ -> False -- One is set but not the other
|
(Just sniDomains, Just _, Just _) -> not (null sniDomains)
|
||||||
|
_ -> False
|
||||||
|
|
||||||
hasSNI = case (tlsSNI tlsConf, tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of
|
when (not hasSingleCert && not hasSNI) $
|
||||||
(Just sniDomains, Just _, Just _) -> not (null sniDomains)
|
Left "TLS configuration must specify either (cert + key) or (sni + default_cert + default_key)"
|
||||||
_ -> False
|
|
||||||
|
|
||||||
-- Must have either single cert or SNI configuration
|
when (hasSingleCert && hasSNI) $
|
||||||
when (not hasSingleCert && not hasSNI) $
|
Left "TLS configuration cannot have both single cert and SNI configuration"
|
||||||
Left "TLS configuration must specify either (cert + key) or (sni + default_cert + default_key)"
|
|
||||||
|
|
||||||
-- Can't have both single cert and SNI
|
when hasSingleCert $
|
||||||
when (hasSingleCert && hasSNI) $
|
case (tlsCert tlsConf, tlsKey tlsConf) of
|
||||||
Left "TLS configuration cannot have both single cert and SNI configuration"
|
(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 hasSNI $
|
||||||
when (hasSingleCert) $ do
|
case (tlsDefaultCert tlsConf, tlsDefaultKey tlsConf) of
|
||||||
case (tlsCert tlsConf, tlsKey tlsConf) of
|
(Just _, Nothing) -> Left "SNI default_cert specified but default_key missing"
|
||||||
(Just _, Nothing) -> Left "TLS cert specified but key missing"
|
(Nothing, Just _) -> Left "SNI default_key specified but default_cert missing"
|
||||||
(Nothing, Just _) -> Left "TLS key specified but cert missing"
|
(Nothing, Nothing) -> Left "SNI configuration requires default_cert and default_key"
|
||||||
_ -> return ()
|
_ -> pure ()
|
||||||
|
|
||||||
-- If SNI, ensure default cert/key are present
|
pure ()
|
||||||
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 ()
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Connection.hs
|
||||||
|
-}
|
||||||
|
{-# LANGUAGE NumericUnderscores #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
|
|
@ -10,14 +15,14 @@ module Aenebris.Connection
|
||||||
, isWebSocketUpgrade
|
, isWebSocketUpgrade
|
||||||
, isStreamingResponse
|
, isStreamingResponse
|
||||||
, getTimeout
|
, getTimeout
|
||||||
|
, microsPerSecond
|
||||||
|
, httpOkStatusCode
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
|
||||||
import Data.CaseInsensitive (CI)
|
|
||||||
import qualified Data.CaseInsensitive as CI
|
import qualified Data.CaseInsensitive as CI
|
||||||
import Data.Maybe (isJust, fromMaybe)
|
import Data.Maybe (isJust)
|
||||||
import Network.HTTP.Types (HeaderName, Status, statusCode)
|
import Network.HTTP.Types (HeaderName, Status, statusCode)
|
||||||
|
|
||||||
data ConnectionState
|
data ConnectionState
|
||||||
|
|
@ -35,23 +40,31 @@ data ConnectionType
|
||||||
| ChunkedStream
|
| ChunkedStream
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
microsPerSecond :: Int
|
||||||
|
microsPerSecond = 1_000_000
|
||||||
|
|
||||||
|
httpOkStatusCode :: Int
|
||||||
|
httpOkStatusCode = 200
|
||||||
|
|
||||||
data TimeoutConfig = TimeoutConfig
|
data TimeoutConfig = TimeoutConfig
|
||||||
{ tcHttpIdle :: Int
|
{ tcHttpIdle :: !Int
|
||||||
, tcWebSocketTunnel :: Int
|
, tcWebSocketTunnel :: !Int
|
||||||
, tcStreamingResponse :: Int
|
, tcStreamingResponse :: !Int
|
||||||
, tcProxyPingInterval :: Int
|
, tcProxyPingInterval :: !Int
|
||||||
, tcPongTimeout :: Int
|
, tcPongTimeout :: !Int
|
||||||
, tcConnectTimeout :: Int
|
, tcConnectTimeout :: !Int
|
||||||
|
, tcUpstreamReadSeconds :: !Int
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTimeoutConfig :: TimeoutConfig
|
defaultTimeoutConfig :: TimeoutConfig
|
||||||
defaultTimeoutConfig = TimeoutConfig
|
defaultTimeoutConfig = TimeoutConfig
|
||||||
{ tcHttpIdle = 60
|
{ tcHttpIdle = 60
|
||||||
, tcWebSocketTunnel = 3600
|
, tcWebSocketTunnel = 3600
|
||||||
, tcStreamingResponse = 3600
|
, tcStreamingResponse = 3600
|
||||||
, tcProxyPingInterval = 30
|
, tcProxyPingInterval = 30
|
||||||
, tcPongTimeout = 10
|
, tcPongTimeout = 10
|
||||||
, tcConnectTimeout = 5
|
, tcConnectTimeout = 5
|
||||||
|
, tcUpstreamReadSeconds = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
getTimeout :: TimeoutConfig -> ConnectionState -> Int
|
getTimeout :: TimeoutConfig -> ConnectionState -> Int
|
||||||
|
|
@ -96,4 +109,7 @@ isStreamingResponse status headers =
|
||||||
|
|
||||||
hasContentLength = isJust (lookup "Content-Length" headers)
|
hasContentLength = isJust (lookup "Content-Length" headers)
|
||||||
|
|
||||||
isUnknownLength = statusCode status == 200 && not hasContentLength && not hasTransferEncodingChunked
|
isUnknownLength =
|
||||||
|
statusCode status == httpOkStatusCode
|
||||||
|
&& not hasContentLength
|
||||||
|
&& not hasTransferEncodingChunked
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,11 @@ import Control.Concurrent.STM
|
||||||
, readTVar
|
, readTVar
|
||||||
, writeTVar
|
, writeTVar
|
||||||
)
|
)
|
||||||
|
import Aenebris.Net.IP (sockAddrToIPBytes)
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
import Network.Socket
|
import Network.Socket (SockAddr)
|
||||||
( HostAddress6
|
|
||||||
, SockAddr(..)
|
|
||||||
, hostAddress6ToTuple
|
|
||||||
, hostAddressToTuple
|
|
||||||
)
|
|
||||||
import Numeric (showHex)
|
|
||||||
import Text.Printf (printf)
|
|
||||||
|
|
||||||
defaultPerIPLimit :: Int
|
defaultPerIPLimit :: Int
|
||||||
defaultPerIPLimit = 16
|
defaultPerIPLimit = 16
|
||||||
|
|
@ -86,24 +79,10 @@ currentCount ConnLimiter{..} ip = do
|
||||||
pure (Map.findWithDefault 0 ip m)
|
pure (Map.findWithDefault 0 ip m)
|
||||||
|
|
||||||
connLimitOnOpen :: ConnLimiter -> SockAddr -> IO Bool
|
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 :: ConnLimiter -> SockAddr -> IO ()
|
||||||
connLimitOnClose cl sa = atomically (release cl (ipBytesFromSockAddr sa))
|
connLimitOnClose cl sa = atomically (release cl (sockAddrToIPBytes sa))
|
||||||
|
|
||||||
ipBytesFromSockAddr :: SockAddr -> ByteString
|
ipBytesFromSockAddr :: SockAddr -> ByteString
|
||||||
ipBytesFromSockAddr (SockAddrInet _ ha) =
|
ipBytesFromSockAddr = sockAddrToIPBytes
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,6 @@ refererHeaderName = "referer"
|
||||||
acceptLanguageHeaderName :: CI ByteString
|
acceptLanguageHeaderName :: CI ByteString
|
||||||
acceptLanguageHeaderName = "accept-language"
|
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 :: ByteString -> ByteString
|
||||||
acceptLanguagePrefix bs =
|
acceptLanguagePrefix bs =
|
||||||
let firstTag = BS.takeWhile (\c -> c /= 0x2c && c /= 0x3b && c /= 0x20) 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
|
filter (\(k, _) -> k /= cookieHeaderName && k /= refererHeaderName) rawHeaders
|
||||||
headerCount = padTwo (length filteredHeaders)
|
headerCount = padTwo (length filteredHeaders)
|
||||||
langPrefix = maybe "0000" acceptLanguagePrefix
|
langPrefix = maybe "0000" acceptLanguagePrefix
|
||||||
$ lookupCI acceptLanguageHeaderName rawHeaders
|
$ lookup acceptLanguageHeaderName rawHeaders
|
||||||
partA = BS.concat [method, version, cookieFlag, refererFlag, headerCount, langPrefix]
|
partA = BS.concat [method, version, cookieFlag, refererFlag, headerCount, langPrefix]
|
||||||
headerNameList = BS.intercalate "," (map (CI.original . fst) filteredHeaders)
|
headerNameList = BS.intercalate "," (map (CI.original . fst) filteredHeaders)
|
||||||
headerHash = hashTruncated headerNameList
|
headerHash = hashTruncated headerNameList
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,11 @@ import qualified Data.ByteString.Char8 as BC
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
import qualified Data.ByteString.Lazy as LBS
|
||||||
import Data.GeoIP2 (GeoDB, GeoResult(..), AS(..), findGeoData, openGeoDB)
|
import Data.GeoIP2 (GeoDB, GeoResult(..), AS(..), findGeoData, openGeoDB)
|
||||||
import Data.IP (IP(..), fromHostAddress, fromHostAddress6)
|
import Data.IP (IP(..), fromHostAddress, fromHostAddress6)
|
||||||
|
import Data.List (minimumBy)
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.Maybe (fromMaybe, isJust)
|
import Data.Maybe (fromMaybe, isJust)
|
||||||
|
import Data.Ord (comparing)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as TE
|
import qualified Data.Text.Encoding as TE
|
||||||
|
|
@ -95,6 +97,9 @@ defaultGeoSweepIntervalMicros = 60_000_000
|
||||||
defaultGeoFlaggedAsns :: [Int]
|
defaultGeoFlaggedAsns :: [Int]
|
||||||
defaultGeoFlaggedAsns = []
|
defaultGeoFlaggedAsns = []
|
||||||
|
|
||||||
|
defaultGeoAsnCountCap :: Int
|
||||||
|
defaultGeoAsnCountCap = 200_000
|
||||||
|
|
||||||
geoResponseHeaderName :: HeaderName
|
geoResponseHeaderName :: HeaderName
|
||||||
geoResponseHeaderName = "x-aenebris-geo"
|
geoResponseHeaderName = "x-aenebris-geo"
|
||||||
|
|
||||||
|
|
@ -262,9 +267,20 @@ bumpAsnCounter Geo{..} n now = do
|
||||||
| now - awWindowStart w < window ->
|
| now - awWindowStart w < window ->
|
||||||
w { awCount = awCount w + 1 }
|
w { awCount = awCount w + 1 }
|
||||||
_ -> AsnWindow { awCount = 1, awWindowStart = now }
|
_ -> 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)
|
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 -> Int -> Double
|
||||||
asnConcentrationScore Geo{..} count =
|
asnConcentrationScore Geo{..} count =
|
||||||
let threshold = max 1 (gcConcentrationThreshold geoConfig)
|
let threshold = max 1 (gcConcentrationThreshold geoConfig)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
HealthCheck.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module Aenebris.HealthCheck
|
module Aenebris.HealthCheck
|
||||||
( HealthCheckConfig(..)
|
( HealthCheckConfig(..)
|
||||||
|
|
@ -10,10 +14,11 @@ module Aenebris.HealthCheck
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Aenebris.Backend
|
import Aenebris.Backend
|
||||||
|
import Aenebris.Connection (httpOkStatusCode, microsPerSecond)
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Concurrent (threadDelay)
|
||||||
import Control.Concurrent.Async
|
import Control.Concurrent.Async
|
||||||
import Control.Concurrent.STM
|
import Control.Concurrent.STM
|
||||||
import Control.Monad (forever)
|
import Control.Monad (forever, zipWithM_)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Time.Clock (getCurrentTime)
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
|
@ -21,71 +26,67 @@ import Network.HTTP.Client
|
||||||
import Network.HTTP.Types.Status (statusCode)
|
import Network.HTTP.Types.Status (statusCode)
|
||||||
import System.Timeout (timeout)
|
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
|
data HealthCheckConfig = HealthCheckConfig
|
||||||
{ hcInterval :: Int -- Seconds between checks
|
{ hcInterval :: !Int
|
||||||
, hcTimeout :: Int -- Request timeout (seconds)
|
, hcTimeout :: !Int
|
||||||
, hcEndpoint :: Text -- Health endpoint path (e.g., "/health")
|
, hcEndpoint :: !Text
|
||||||
, hcMaxFailures :: Int -- Failures before marking unhealthy
|
, hcMaxFailures :: !Int
|
||||||
, hcRecoveryAttempts :: Int -- Successes before marking healthy
|
, hcRecoveryAttempts :: !Int
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Default health check configuration
|
|
||||||
defaultHealthCheckConfig :: HealthCheckConfig
|
defaultHealthCheckConfig :: HealthCheckConfig
|
||||||
defaultHealthCheckConfig = HealthCheckConfig
|
defaultHealthCheckConfig = HealthCheckConfig
|
||||||
{ hcInterval = 10
|
{ hcInterval = defaultHealthCheckIntervalSeconds
|
||||||
, hcTimeout = 2
|
, hcTimeout = defaultHealthCheckTimeoutSeconds
|
||||||
, hcEndpoint = "/health"
|
, hcEndpoint = defaultHealthCheckEndpoint
|
||||||
, hcMaxFailures = 3
|
, hcMaxFailures = defaultHealthCheckMaxFailures
|
||||||
, hcRecoveryAttempts = 2
|
, hcRecoveryAttempts = defaultHealthCheckRecoveryAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Start health checker (returns Async handle for stopping)
|
startHealthChecker
|
||||||
startHealthChecker :: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO (Async ())
|
:: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO (Async ())
|
||||||
startHealthChecker manager config backends =
|
startHealthChecker manager config backends =
|
||||||
async $ healthCheckLoop manager config backends
|
async (healthCheckLoop manager config backends)
|
||||||
|
|
||||||
-- | Stop health checker
|
|
||||||
stopHealthChecker :: Async () -> IO ()
|
stopHealthChecker :: Async () -> IO ()
|
||||||
stopHealthChecker = cancel
|
stopHealthChecker = cancel
|
||||||
|
|
||||||
-- | Main health check loop
|
|
||||||
healthCheckLoop :: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO ()
|
healthCheckLoop :: Manager -> HealthCheckConfig -> [RuntimeBackend] -> IO ()
|
||||||
healthCheckLoop manager config backends = forever $ do
|
healthCheckLoop manager config backends = forever $ do
|
||||||
-- Check all backends concurrently
|
|
||||||
results <- mapConcurrently (performHealthCheck manager config) backends
|
results <- mapConcurrently (performHealthCheck manager config) backends
|
||||||
|
|
||||||
-- Update backend states based on results
|
|
||||||
atomically $ zipWithM_ (updateBackendState config) backends 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 -> HealthCheckConfig -> RuntimeBackend -> IO Bool
|
||||||
performHealthCheck manager config backend = do
|
performHealthCheck manager config backend = do
|
||||||
let url = "http://" ++ T.unpack (rbHost backend) ++ T.unpack (hcEndpoint config)
|
let url = "http://" ++ T.unpack (rbHost backend) ++ T.unpack (hcEndpoint config)
|
||||||
|
result <- timeout (hcTimeout config * microsPerSecond) $ do
|
||||||
-- Try to make request with timeout
|
|
||||||
result <- timeout (hcTimeout config * 1000000) $ do
|
|
||||||
req <- parseRequest url
|
req <- parseRequest url
|
||||||
response <- httpLbs req manager
|
response <- httpLbs req manager
|
||||||
return $ statusCode (responseStatus response) == 200
|
pure (statusCode (responseStatus response) == httpOkStatusCode)
|
||||||
|
|
||||||
-- Update last check time
|
|
||||||
now <- getCurrentTime
|
now <- getCurrentTime
|
||||||
atomically $ writeTVar (rbLastHealthCheck backend) (Just now)
|
atomically $ writeTVar (rbLastHealthCheck backend) (Just now)
|
||||||
|
pure $ case result of
|
||||||
return $ case result of
|
|
||||||
Just True -> True
|
Just True -> True
|
||||||
_ -> False
|
_ -> False
|
||||||
|
|
||||||
-- | Update backend state based on health check result
|
|
||||||
updateBackendState :: HealthCheckConfig -> RuntimeBackend -> Bool -> STM ()
|
updateBackendState :: HealthCheckConfig -> RuntimeBackend -> Bool -> STM ()
|
||||||
updateBackendState config backend healthy =
|
updateBackendState config backend healthy =
|
||||||
if healthy
|
if healthy
|
||||||
then recordSuccess backend (hcRecoveryAttempts config)
|
then recordSuccess backend (hcRecoveryAttempts config)
|
||||||
else recordFailure backend (hcMaxFailures 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)
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ import qualified Data.Text.Encoding as TE
|
||||||
import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime)
|
import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime)
|
||||||
import Data.Word (Word64)
|
import Data.Word (Word64)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
import Network.HTTP.Types (Status, mkStatus)
|
import Network.HTTP.Types (status200, status404)
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
( Middleware
|
( Middleware
|
||||||
, Response
|
, Response
|
||||||
|
|
@ -219,12 +219,6 @@ trapLabel :: TrapPattern -> ByteString
|
||||||
trapLabel (TrapExact e) = e
|
trapLabel (TrapExact e) = e
|
||||||
trapLabel (TrapPrefix p) = p <> "*"
|
trapLabel (TrapPrefix p) = p <> "*"
|
||||||
|
|
||||||
status404 :: Status
|
|
||||||
status404 = mkStatus 404 "Not Found"
|
|
||||||
|
|
||||||
status200 :: Status
|
|
||||||
status200 = mkStatus 200 "OK"
|
|
||||||
|
|
||||||
honeypotMiddleware :: HoneypotConfig -> Maybe IPJail -> Middleware
|
honeypotMiddleware :: HoneypotConfig -> Maybe IPJail -> Middleware
|
||||||
honeypotMiddleware cfg@HoneypotConfig{..} mJail app req respond
|
honeypotMiddleware cfg@HoneypotConfig{..} mJail app req respond
|
||||||
| hpServeRobotsTxt && requestMethod req == "GET"
|
| hpServeRobotsTxt && requestMethod req == "GET"
|
||||||
|
|
@ -280,7 +274,7 @@ robotsResponse cfg =
|
||||||
robotsTxtBody :: HoneypotConfig -> ByteString
|
robotsTxtBody :: HoneypotConfig -> ByteString
|
||||||
robotsTxtBody HoneypotConfig{..} = BS.concat $
|
robotsTxtBody HoneypotConfig{..} = BS.concat $
|
||||||
[ "User-agent: *\n"
|
[ "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"
|
, "# paths is treated as a violation signal regardless of declared UA.\n"
|
||||||
] <> map disallowLine hpPatterns
|
] <> map disallowLine hpPatterns
|
||||||
where
|
where
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
LoadBalancer.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
|
||||||
module Aenebris.LoadBalancer
|
module Aenebris.LoadBalancer
|
||||||
|
|
@ -9,142 +13,94 @@ module Aenebris.LoadBalancer
|
||||||
|
|
||||||
import Aenebris.Backend
|
import Aenebris.Backend
|
||||||
import Control.Concurrent.STM
|
import Control.Concurrent.STM
|
||||||
|
import Control.Monad (filterM, forM_)
|
||||||
import Data.IORef
|
import Data.IORef
|
||||||
import Data.List (minimumBy, find)
|
import Data.List (maximumBy, minimumBy)
|
||||||
import Data.Ord (comparing)
|
import Data.Ord (comparing)
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import Data.Vector (Vector, (!))
|
import Data.Vector (Vector, (!))
|
||||||
|
|
||||||
-- | Load balancing strategy
|
initialRoundRobinIndex :: Int
|
||||||
|
initialRoundRobinIndex = 0
|
||||||
|
|
||||||
data LoadBalancerStrategy
|
data LoadBalancerStrategy
|
||||||
= RoundRobin
|
= RoundRobin
|
||||||
| LeastConnections
|
| LeastConnections
|
||||||
| WeightedRoundRobin
|
| WeightedRoundRobin
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
-- | Load balancer state
|
|
||||||
data LoadBalancer = LoadBalancer
|
data LoadBalancer = LoadBalancer
|
||||||
{ lbBackends :: Vector RuntimeBackend
|
{ lbBackends :: !(Vector RuntimeBackend)
|
||||||
, lbStrategy :: LoadBalancerStrategy
|
, lbStrategy :: !LoadBalancerStrategy
|
||||||
, lbRRCounter :: IORef Int -- For round robin
|
, lbRRCounter :: !(IORef Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Create a load balancer for given backends
|
createLoadBalancer
|
||||||
createLoadBalancer :: LoadBalancerStrategy -> [RuntimeBackend] -> IO LoadBalancer
|
:: LoadBalancerStrategy -> [RuntimeBackend] -> IO LoadBalancer
|
||||||
createLoadBalancer strategy backends = do
|
createLoadBalancer strategy backends = do
|
||||||
counter <- newIORef 0
|
counter <- newIORef initialRoundRobinIndex
|
||||||
return LoadBalancer
|
pure LoadBalancer
|
||||||
{ lbBackends = V.fromList backends
|
{ lbBackends = V.fromList backends
|
||||||
, lbStrategy = strategy
|
, lbStrategy = strategy
|
||||||
, lbRRCounter = counter
|
, lbRRCounter = counter
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Select a backend using the configured strategy
|
|
||||||
selectBackend :: LoadBalancer -> IO (Maybe RuntimeBackend)
|
selectBackend :: LoadBalancer -> IO (Maybe RuntimeBackend)
|
||||||
selectBackend lb =
|
selectBackend lb = case lbStrategy lb of
|
||||||
case lbStrategy lb of
|
RoundRobin -> selectRoundRobin lb
|
||||||
RoundRobin -> selectRoundRobin lb
|
LeastConnections -> selectLeastConnections lb
|
||||||
LeastConnections -> selectLeastConnections lb
|
WeightedRoundRobin -> selectWeightedRR lb
|
||||||
WeightedRoundRobin -> selectWeightedRR lb
|
|
||||||
|
|
||||||
-- Round-Robin Implementation (IORef-based, fastest)
|
|
||||||
selectRoundRobin :: LoadBalancer -> IO (Maybe RuntimeBackend)
|
selectRoundRobin :: LoadBalancer -> IO (Maybe RuntimeBackend)
|
||||||
selectRoundRobin LoadBalancer{..} = do
|
selectRoundRobin LoadBalancer{..} = do
|
||||||
let backends = lbBackends
|
let backends = lbBackends
|
||||||
len = V.length backends
|
len = V.length backends
|
||||||
|
|
||||||
if len == 0
|
if len == 0
|
||||||
then return Nothing
|
then pure Nothing
|
||||||
else do
|
else do
|
||||||
-- Get next index
|
|
||||||
idx <- atomicModifyIORef' lbRRCounter $ \i ->
|
idx <- atomicModifyIORef' lbRRCounter $ \i ->
|
||||||
let next = (i + 1) `mod` len
|
let next = (i + 1) `mod` len
|
||||||
in (next, i)
|
in (next, i)
|
||||||
|
|
||||||
-- Find next healthy backend (try all, wrapping around)
|
|
||||||
findHealthyBackend backends idx len
|
findHealthyBackend backends idx len
|
||||||
|
|
||||||
-- | Find next healthy backend starting from index
|
findHealthyBackend
|
||||||
findHealthyBackend :: Vector RuntimeBackend -> Int -> Int -> IO (Maybe RuntimeBackend)
|
:: Vector RuntimeBackend -> Int -> Int -> IO (Maybe RuntimeBackend)
|
||||||
findHealthyBackend backends startIdx totalBackends =
|
findHealthyBackend backends startIdx totalBackends = go startIdx totalBackends
|
||||||
go startIdx totalBackends
|
|
||||||
where
|
where
|
||||||
len = V.length backends
|
len = V.length backends
|
||||||
|
|
||||||
go currentIdx remaining
|
go currentIdx remaining
|
||||||
| remaining <= 0 = return Nothing -- Tried all, none healthy
|
| remaining <= 0 = pure Nothing
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
let backend = backends ! currentIdx
|
let backend = backends ! currentIdx
|
||||||
healthy <- atomically $ isHealthy backend
|
healthy <- atomically (isHealthy backend)
|
||||||
|
|
||||||
if healthy
|
if healthy
|
||||||
then return (Just backend)
|
then pure (Just backend)
|
||||||
else go ((currentIdx + 1) `mod` len) (remaining - 1)
|
else go ((currentIdx + 1) `mod` len) (remaining - 1)
|
||||||
|
|
||||||
|
|
||||||
-- Least Connections Implementation (STM-based)
|
|
||||||
selectLeastConnections :: LoadBalancer -> IO (Maybe RuntimeBackend)
|
selectLeastConnections :: LoadBalancer -> IO (Maybe RuntimeBackend)
|
||||||
selectLeastConnections LoadBalancer{..} = atomically $ do
|
selectLeastConnections LoadBalancer{..} = atomically $ do
|
||||||
let backends = V.toList lbBackends
|
let backends = V.toList lbBackends
|
||||||
|
|
||||||
-- Filter to only healthy backends
|
|
||||||
healthy <- filterM isHealthy backends
|
healthy <- filterM isHealthy backends
|
||||||
|
|
||||||
case healthy of
|
case healthy of
|
||||||
[] -> return Nothing
|
[] -> pure Nothing
|
||||||
backends' -> do
|
chosen -> do
|
||||||
-- Get connection counts for all healthy backends
|
counts <- mapM getConnectionCount chosen
|
||||||
counts <- mapM getConnectionCount backends'
|
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 -> IO (Maybe RuntimeBackend)
|
||||||
selectWeightedRR LoadBalancer{..} = atomically $ do
|
selectWeightedRR LoadBalancer{..} = atomically $ do
|
||||||
let backends = V.toList lbBackends
|
let backends = V.toList lbBackends
|
||||||
|
|
||||||
-- Filter to only healthy backends
|
|
||||||
healthy <- filterM isHealthy backends
|
healthy <- filterM isHealthy backends
|
||||||
|
|
||||||
case healthy of
|
case healthy of
|
||||||
[] -> return Nothing
|
[] -> pure Nothing
|
||||||
backends' -> do
|
chosen -> do
|
||||||
-- Step 1: Increase each backend's current weight by its base weight
|
forM_ chosen $ \rb ->
|
||||||
forM_ backends' $ \rb -> do
|
modifyTVar' (rbCurrentWeight rb) (+ rbWeight rb)
|
||||||
currentW <- readTVar (rbCurrentWeight rb)
|
tagged <- mapM
|
||||||
let newWeight = currentW + rbWeight rb
|
(\rb -> (\w -> (w, rb)) <$> readTVar (rbCurrentWeight rb))
|
||||||
writeTVar (rbCurrentWeight rb) newWeight
|
chosen
|
||||||
|
let (_, picked) = maximumBy (comparing fst) tagged
|
||||||
-- Step 2: Select backend with maximum current weight
|
totalWeight = sum (map rbWeight chosen)
|
||||||
weights <- mapM getCurrentWeight backends'
|
modifyTVar' (rbCurrentWeight picked) (subtract totalWeight)
|
||||||
let maxWeight = maximum weights
|
pure (Just picked)
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -494,17 +494,17 @@ extractFeatures FeatureContext{..} req =
|
||||||
let !headers = requestHeaders req
|
let !headers = requestHeaders req
|
||||||
!path = rawPathInfo req
|
!path = rawPathInfo req
|
||||||
!method = requestMethod req
|
!method = requestMethod req
|
||||||
!mAcceptLang = lookupCI acceptLanguageHeader headers
|
!mAcceptLang = lookup acceptLanguageHeader headers
|
||||||
!mUserAgent = lookupCI userAgentHeader headers
|
!mUserAgent = lookup userAgentHeader headers
|
||||||
!mAcceptEnc = lookupCI acceptEncodingHeader headers
|
!mAcceptEnc = lookup acceptEncodingHeader headers
|
||||||
!mReferer = lookupCI refererHeader headers
|
!mReferer = lookup refererHeader headers
|
||||||
!mCookie = lookupCI cookieHeader headers
|
!mCookie = lookup cookieHeader headers
|
||||||
!mSecChUa = lookupCI secChUaHeader headers
|
!mSecChUa = lookup secChUaHeader headers
|
||||||
!mSecChPlat = lookupCI secChUaPlatformHeader headers
|
!mSecChPlat = lookup secChUaPlatformHeader headers
|
||||||
!mSecFetchSite = lookupCI secFetchSiteHeader headers
|
!mSecFetchSite = lookup secFetchSiteHeader headers
|
||||||
!mSecFetchMode = lookupCI secFetchModeHeader headers
|
!mSecFetchMode = lookup secFetchModeHeader headers
|
||||||
!mSecFetchDest = lookupCI secFetchDestHeader headers
|
!mSecFetchDest = lookup secFetchDestHeader headers
|
||||||
!mAccept = lookupCI acceptHeader headers
|
!mAccept = lookup acceptHeader headers
|
||||||
!uaBytes = fromMaybe BS.empty mUserAgent
|
!uaBytes = fromMaybe BS.empty mUserAgent
|
||||||
!uaLen = BS.length uaBytes
|
!uaLen = BS.length uaBytes
|
||||||
!depth = pathDepth path
|
!depth = pathDepth path
|
||||||
|
|
@ -552,8 +552,3 @@ extractFeatures FeatureContext{..} req =
|
||||||
, fHeaderOrderCanonical = boolToDouble
|
, fHeaderOrderCanonical = boolToDouble
|
||||||
(headerOrderIsCanonicalBrowser headers)
|
(headerOrderIsCanonicalBrowser headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
lookupCI :: CI ByteString -> [(CI ByteString, ByteString)] -> Maybe ByteString
|
|
||||||
lookupCI k hs = case filter ((== k) . fst) hs of
|
|
||||||
((_, v) : _) -> Just v
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ module Aenebris.ML.IForest
|
||||||
, minSubsampleForNormalization
|
, minSubsampleForNormalization
|
||||||
, defaultIForestNumTrees
|
, defaultIForestNumTrees
|
||||||
, defaultIForestSubsampleSize
|
, defaultIForestSubsampleSize
|
||||||
|
, maxIForestDepth
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Vector (Vector)
|
import Data.Vector (Vector)
|
||||||
|
|
@ -44,6 +45,12 @@ defaultIForestNumTrees = 100
|
||||||
defaultIForestSubsampleSize :: Int
|
defaultIForestSubsampleSize :: Int
|
||||||
defaultIForestSubsampleSize = 256
|
defaultIForestSubsampleSize = 256
|
||||||
|
|
||||||
|
maxIForestDepth :: Int
|
||||||
|
maxIForestDepth = 64
|
||||||
|
|
||||||
|
depthBoundLeafSize :: Int
|
||||||
|
depthBoundLeafSize = 1
|
||||||
|
|
||||||
data ITree
|
data ITree
|
||||||
= ITreeLeaf !Int
|
= ITreeLeaf !Int
|
||||||
| ITreeSplit !Int !Double !ITree !ITree
|
| ITreeSplit !Int !Double !ITree !ITree
|
||||||
|
|
@ -77,14 +84,17 @@ averagePathLength !trees !fv =
|
||||||
addPath !acc !tree = acc + pathLength tree fv initialDepth
|
addPath !acc !tree = acc + pathLength tree fv initialDepth
|
||||||
|
|
||||||
pathLength :: ITree -> VU.Vector Double -> Int -> Double
|
pathLength :: ITree -> VU.Vector Double -> Int -> Double
|
||||||
pathLength !tree !fv !currentDepth = case tree of
|
pathLength !tree !fv !currentDepth
|
||||||
ITreeLeaf !size ->
|
| currentDepth >= maxIForestDepth =
|
||||||
fromIntegral currentDepth + normalizationConstant size
|
fromIntegral currentDepth + normalizationConstant depthBoundLeafSize
|
||||||
ITreeSplit !featIdx !thr !left !right ->
|
| otherwise = case tree of
|
||||||
let !fval = fv VU.! featIdx
|
ITreeLeaf !size ->
|
||||||
in if fval <= thr
|
fromIntegral currentDepth + normalizationConstant size
|
||||||
then pathLength left fv (currentDepth + 1)
|
ITreeSplit !featIdx !thr !left !right ->
|
||||||
else pathLength right fv (currentDepth + 1)
|
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 :: Int -> Double
|
||||||
normalizationConstant n
|
normalizationConstant n
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ requiredNumClass = 1
|
||||||
requiredNumTreePerIteration :: Int
|
requiredNumTreePerIteration :: Int
|
||||||
requiredNumTreePerIteration = 1
|
requiredNumTreePerIteration = 1
|
||||||
|
|
||||||
|
maxNumLeaves :: Int
|
||||||
|
maxNumLeaves = 4096
|
||||||
|
|
||||||
|
maxNumTrees :: Int
|
||||||
|
maxNumTrees = 10000
|
||||||
|
|
||||||
postParseLineSentinel :: Int
|
postParseLineSentinel :: Int
|
||||||
postParseLineSentinel = -1
|
postParseLineSentinel = -1
|
||||||
|
|
||||||
|
|
@ -355,18 +361,26 @@ requireField key mv = case mv of
|
||||||
("Missing required header key: " <> key))
|
("Missing required header key: " <> key))
|
||||||
|
|
||||||
runTrees :: [(Int, Text)] -> Either ParseError [Tree]
|
runTrees :: [(Int, Text)] -> Either ParseError [Tree]
|
||||||
runTrees lns = case dropWhile (lineIsBlank . snd) lns of
|
runTrees = goTrees 0
|
||||||
[] -> Right []
|
where
|
||||||
((n, ln):rest)
|
goTrees :: Int -> [(Int, Text)] -> Either ParseError [Tree]
|
||||||
| T.strip ln == endOfTreesMarker -> Right []
|
goTrees treeCount lns
|
||||||
| T.isPrefixOf baseTreeKeyPrefix (T.strip ln) -> do
|
| treeCount > maxNumTrees = Left
|
||||||
let (block, after) = break (lineIsBlank . snd) rest
|
(ParseError postParseLineSentinel treeKey
|
||||||
tree <- parseTreeBlock block
|
("Tree count exceeds maxNumTrees "
|
||||||
more <- runTrees after
|
<> T.pack (show maxNumTrees)))
|
||||||
Right (tree : more)
|
| otherwise = case dropWhile (lineIsBlank . snd) lns of
|
||||||
| otherwise -> Left
|
[] -> Right []
|
||||||
(ParseError n treeKey
|
((n, ln):rest)
|
||||||
("Expected 'Tree=N' or 'end of trees', got: " <> ln))
|
| 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 :: [(Int, Text)] -> Either ParseError Tree
|
||||||
parseTreeBlock block = do
|
parseTreeBlock block = do
|
||||||
|
|
@ -428,6 +442,14 @@ assignTreeField acc n key val
|
||||||
finalizeTree :: TreeAcc -> Either ParseError Tree
|
finalizeTree :: TreeAcc -> Either ParseError Tree
|
||||||
finalizeTree acc = do
|
finalizeTree acc = do
|
||||||
nL <- requireField keyNumLeaves (taNumLeaves acc)
|
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)
|
nC <- requireField keyNumCat (taNumCat acc)
|
||||||
leafValues <- requireField keyLeafValue (taLeafValue acc)
|
leafValues <- requireField keyLeafValue (taLeafValue acc)
|
||||||
unless (length leafValues == nL)
|
unless (length leafValues == nL)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ challengeWireText :: ByteString
|
||||||
challengeWireText = "challenge"
|
challengeWireText = "challenge"
|
||||||
|
|
||||||
botBlockBody :: LBS.ByteString
|
botBlockBody :: LBS.ByteString
|
||||||
botBlockBody = "403 Forbidden \x2014 request blocked by Aenebris ML"
|
botBlockBody = "403 Forbidden - request blocked by Aenebris ML"
|
||||||
|
|
||||||
challengePageBody :: LBS.ByteString
|
challengePageBody :: LBS.ByteString
|
||||||
challengePageBody =
|
challengePageBody =
|
||||||
|
|
|
||||||
|
|
@ -329,15 +329,19 @@ validateCategoricalNode
|
||||||
:: Int -> Int -> Tree -> Int -> Int -> Int -> Int -> Either String ()
|
:: Int -> Int -> Tree -> Int -> Int -> Int -> Int -> Either String ()
|
||||||
validateCategoricalNode featureCount nodeCount t i fIdx lIdx rIdx = do
|
validateCategoricalNode featureCount nodeCount t i fIdx lIdx rIdx = do
|
||||||
validateSplitNode featureCount nodeCount i fIdx lIdx rIdx
|
validateSplitNode featureCount nodeCount i fIdx lIdx rIdx
|
||||||
let catIdx = floor (treeThreshold t VU.! i) :: Int
|
let rawThreshold = treeThreshold t VU.! i
|
||||||
nBound = VU.length (treeCatBoundaries t)
|
catIdx = floor rawThreshold :: Int
|
||||||
if nBound < 2
|
nBound = VU.length (treeCatBoundaries t)
|
||||||
|
if fromIntegral catIdx /= rawThreshold
|
||||||
then Left ("Categorical node " <> show i
|
then Left ("Categorical node " <> show i
|
||||||
<> " requires non-empty cat_boundaries")
|
<> " has non-integer threshold " <> show rawThreshold)
|
||||||
else if catIdx < 0 || catIdx >= nBound - 1
|
else if nBound < 2
|
||||||
then Left ("Categorical node " <> show i
|
then Left ("Categorical node " <> show i
|
||||||
<> " has out-of-range bitmap slice index " <> show catIdx)
|
<> " requires non-empty cat_boundaries")
|
||||||
else Right ()
|
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 :: Int -> Ensemble -> Either String ()
|
||||||
validateEnsemble expectedFeatures ens = do
|
validateEnsemble expectedFeatures ens = do
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Redirect.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module Aenebris.Middleware.Redirect
|
module Aenebris.Middleware.Redirect
|
||||||
|
|
@ -7,38 +11,44 @@ module Aenebris.Middleware.Redirect
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Network.HTTP.Types (status301, hLocation)
|
import Network.HTTP.Types (hLocation, status301)
|
||||||
import Network.Wai (Middleware, responseLBS, requestHeaderHost, rawPathInfo, rawQueryString, isSecure)
|
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 :: Middleware
|
||||||
httpsRedirect = httpsRedirectWithPort Nothing
|
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 :: Maybe Int -> Middleware
|
||||||
httpsRedirectWithPort httpsPort app req respond
|
httpsRedirectWithPort httpsPort app req respond
|
||||||
| isSecure req = app req respond -- Already HTTPS, pass through
|
| isSecure req = app req respond
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
-- Get host from Host header
|
let hostHeader = fromMaybe defaultHostFallback (requestHeaderHost req)
|
||||||
let hostHeader = fromMaybe "localhost" $ requestHeaderHost req
|
|
||||||
|
|
||||||
-- Build HTTPS URL with optional port
|
|
||||||
host = case httpsPort of
|
host = case httpsPort of
|
||||||
Nothing -> hostHeader -- Standard 443, don't include port
|
Nothing -> hostHeader
|
||||||
Just 443 -> hostHeader -- Standard 443, don't include port
|
Just port | port == standardHttpsPort -> hostHeader
|
||||||
Just port -> hostHeader <> ":" <> BS.pack (show port)
|
Just port -> hostHeader <> ":" <> BS.pack (show port)
|
||||||
|
path = rawPathInfo req
|
||||||
-- Get path and query string (already encoded in rawPathInfo)
|
query = rawQueryString req
|
||||||
path = rawPathInfo req
|
redirectUrl = httpsScheme <> host <> path <> query
|
||||||
query = rawQueryString req
|
|
||||||
|
|
||||||
-- Build full redirect URL
|
|
||||||
redirectUrl = "https://" <> host <> path <> query
|
|
||||||
|
|
||||||
-- Send 301 permanent redirect
|
|
||||||
respond $ responseLBS
|
respond $ responseLBS
|
||||||
status301
|
status301
|
||||||
[(hLocation, redirectUrl)]
|
[(hLocation, redirectUrl)]
|
||||||
"Redirecting to HTTPS"
|
(BS.fromStrict redirectBody)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Security.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module Aenebris.Middleware.Security
|
module Aenebris.Middleware.Security
|
||||||
|
|
@ -11,125 +15,101 @@ module Aenebris.Middleware.Security
|
||||||
|
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import qualified Data.CaseInsensitive as CI
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
import Data.Maybe (catMaybes)
|
||||||
import Network.HTTP.Types (Header, ResponseHeaders)
|
import Network.HTTP.Types (Header, ResponseHeaders)
|
||||||
import Network.Wai (Middleware, mapResponseHeaders)
|
import Network.Wai (Middleware, mapResponseHeaders)
|
||||||
|
|
||||||
-- | Security level presets
|
|
||||||
data SecurityLevel
|
data SecurityLevel
|
||||||
= Testing -- Short HSTS, permissive CSP, for development
|
= Testing
|
||||||
| Production -- Balanced security for production
|
| Production
|
||||||
| Strict -- Maximum security, strict CSP, HSTS preload
|
| Strict
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | Security configuration
|
|
||||||
data SecurityConfig = SecurityConfig
|
data SecurityConfig = SecurityConfig
|
||||||
{ scHSTS :: Maybe ByteString -- Strict-Transport-Security header
|
{ scHSTS :: !(Maybe ByteString)
|
||||||
, scCSP :: Maybe ByteString -- Content-Security-Policy header
|
, scCSP :: !(Maybe ByteString)
|
||||||
, scFrameOptions :: Maybe ByteString -- X-Frame-Options header
|
, scFrameOptions :: !(Maybe ByteString)
|
||||||
, scContentTypeOptions :: Bool -- X-Content-Type-Options: nosniff
|
, scContentTypeOptions :: !Bool
|
||||||
, scReferrerPolicy :: Maybe ByteString -- Referrer-Policy header
|
, scReferrerPolicy :: !(Maybe ByteString)
|
||||||
, scPermissionsPolicy :: Maybe ByteString -- Permissions-Policy header
|
, scPermissionsPolicy :: !(Maybe ByteString)
|
||||||
, scXSSProtection :: Maybe ByteString -- X-XSS-Protection (legacy, but some crawlers check)
|
, scXSSProtection :: !(Maybe ByteString)
|
||||||
, scExpectCT :: Maybe ByteString -- Expect-CT (transitional)
|
, scExpectCT :: !(Maybe ByteString)
|
||||||
, scServerHeader :: Maybe ByteString -- Server header (hide or customize)
|
, scServerHeader :: !(Maybe ByteString)
|
||||||
, scRemovePoweredBy :: Bool -- Remove X-Powered-By headers
|
, scRemovePoweredBy :: !Bool
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
-- | Testing/development security configuration
|
|
||||||
-- Use short HSTS for easy testing, permissive CSP
|
|
||||||
testingSecurityConfig :: SecurityConfig
|
testingSecurityConfig :: SecurityConfig
|
||||||
testingSecurityConfig = SecurityConfig
|
testingSecurityConfig = SecurityConfig
|
||||||
{ scHSTS = Just "max-age=300" -- 5 minutes for testing
|
{ 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'"
|
, scCSP = Just "default-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
|
||||||
, scFrameOptions = Just "SAMEORIGIN"
|
, scFrameOptions = Just "SAMEORIGIN"
|
||||||
, scContentTypeOptions = True
|
, scContentTypeOptions = True
|
||||||
, scReferrerPolicy = Just "strict-origin-when-cross-origin"
|
, scReferrerPolicy = Just "strict-origin-when-cross-origin"
|
||||||
, scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=()"
|
, scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=()"
|
||||||
, scXSSProtection = Just "1; mode=block"
|
, scXSSProtection = Just "1; mode=block"
|
||||||
, scExpectCT = Nothing
|
, scExpectCT = Nothing
|
||||||
, scServerHeader = Just "Aenebris/0.1.0"
|
, scServerHeader = Just "Aenebris/0.1.0"
|
||||||
, scRemovePoweredBy = True
|
, scRemovePoweredBy = True
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Production security configuration
|
|
||||||
-- Balanced security, 1-month HSTS
|
|
||||||
defaultSecurityConfig :: SecurityConfig
|
defaultSecurityConfig :: SecurityConfig
|
||||||
defaultSecurityConfig = SecurityConfig
|
defaultSecurityConfig = SecurityConfig
|
||||||
{ scHSTS = Just "max-age=2592000; includeSubDomains" -- 30 days
|
{ 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'"
|
, 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"
|
, scFrameOptions = Just "DENY"
|
||||||
, scContentTypeOptions = True
|
, scContentTypeOptions = True
|
||||||
, scReferrerPolicy = Just "strict-origin-when-cross-origin"
|
, scReferrerPolicy = Just "strict-origin-when-cross-origin"
|
||||||
, scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
|
, scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
|
||||||
, scXSSProtection = Just "1; mode=block"
|
, scXSSProtection = Just "1; mode=block"
|
||||||
, scExpectCT = Just "max-age=86400, enforce"
|
, scExpectCT = Just "max-age=86400, enforce"
|
||||||
, scServerHeader = Just "Aenebris" -- Don't reveal version in production
|
, scServerHeader = Just "Aenebris"
|
||||||
, scRemovePoweredBy = True
|
, scRemovePoweredBy = True
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Strict security configuration for maximum protection
|
|
||||||
-- 2-year HSTS with preload, very restrictive CSP
|
|
||||||
strictSecurityConfig :: SecurityConfig
|
strictSecurityConfig :: SecurityConfig
|
||||||
strictSecurityConfig = SecurityConfig
|
strictSecurityConfig = SecurityConfig
|
||||||
{ scHSTS = Just "max-age=63072000; includeSubDomains; preload" -- 2 years + preload
|
{ 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"
|
, 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"
|
, scFrameOptions = Just "DENY"
|
||||||
, scContentTypeOptions = True
|
, scContentTypeOptions = True
|
||||||
, scReferrerPolicy = Just "no-referrer" -- Strictest, no referrer leakage
|
, scReferrerPolicy = Just "no-referrer"
|
||||||
, scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), bluetooth=(), display-capture=(), document-domain=()"
|
, scPermissionsPolicy = Just "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), bluetooth=(), display-capture=(), document-domain=()"
|
||||||
, scXSSProtection = Just "1; mode=block"
|
, scXSSProtection = Just "1; mode=block"
|
||||||
, scExpectCT = Just "max-age=86400, enforce"
|
, scExpectCT = Just "max-age=86400, enforce"
|
||||||
, scServerHeader = Nothing -- Hide completely
|
, scServerHeader = Nothing
|
||||||
, scRemovePoweredBy = True
|
, scRemovePoweredBy = True
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Middleware that adds security headers to all responses
|
|
||||||
addSecurityHeaders :: SecurityConfig -> Middleware
|
addSecurityHeaders :: SecurityConfig -> Middleware
|
||||||
addSecurityHeaders config app req respond =
|
addSecurityHeaders config app req respond =
|
||||||
app req $ \res ->
|
app req $ \res ->
|
||||||
respond $ mapResponseHeaders (addHeaders config) res
|
respond $ mapResponseHeaders (addHeaders config) res
|
||||||
|
|
||||||
-- | Add security headers to response headers
|
|
||||||
addHeaders :: SecurityConfig -> ResponseHeaders -> ResponseHeaders
|
addHeaders :: SecurityConfig -> ResponseHeaders -> ResponseHeaders
|
||||||
addHeaders config headers =
|
addHeaders config headers =
|
||||||
let
|
let cleaned = if scRemovePoweredBy config
|
||||||
-- Remove headers we want to control
|
then filter (not . isPoweredBy) headers
|
||||||
cleaned = if scRemovePoweredBy config
|
else headers
|
||||||
then filter (not . isPoweredBy) headers
|
newHeaders = catMaybes
|
||||||
else headers
|
[ fmap (\v -> ("Strict-Transport-Security", v)) (scHSTS config)
|
||||||
|
, fmap (\v -> ("Content-Security-Policy", v)) (scCSP config)
|
||||||
-- Build new security headers
|
, fmap (\v -> ("X-Frame-Options", v)) (scFrameOptions config)
|
||||||
newHeaders = catMaybes
|
, if scContentTypeOptions config
|
||||||
[ fmap (\v -> ("Strict-Transport-Security", v)) (scHSTS config)
|
then Just ("X-Content-Type-Options", "nosniff")
|
||||||
, fmap (\v -> ("Content-Security-Policy", v)) (scCSP config)
|
else Nothing
|
||||||
, fmap (\v -> ("X-Frame-Options", v)) (scFrameOptions config)
|
, fmap (\v -> ("Referrer-Policy", v)) (scReferrerPolicy config)
|
||||||
, if scContentTypeOptions config
|
, fmap (\v -> ("Permissions-Policy", v)) (scPermissionsPolicy config)
|
||||||
then Just ("X-Content-Type-Options", "nosniff")
|
, fmap (\v -> ("X-XSS-Protection", v)) (scXSSProtection config)
|
||||||
else Nothing
|
, fmap (\v -> ("Expect-CT", v)) (scExpectCT config)
|
||||||
, fmap (\v -> ("Referrer-Policy", v)) (scReferrerPolicy config)
|
]
|
||||||
, fmap (\v -> ("Permissions-Policy", v)) (scPermissionsPolicy config)
|
serverHeader = case scServerHeader config of
|
||||||
, fmap (\v -> ("X-XSS-Protection", v)) (scXSSProtection config)
|
Just v -> [("Server", v)]
|
||||||
, fmap (\v -> ("Expect-CT", v)) (scExpectCT config)
|
Nothing -> []
|
||||||
]
|
withoutServer = filter (not . isServerHeader) cleaned
|
||||||
|
|
||||||
-- 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
|
|
||||||
|
|
||||||
in withoutServer ++ newHeaders ++ serverHeader
|
in withoutServer ++ newHeaders ++ serverHeader
|
||||||
|
|
||||||
-- | Check if header is X-Powered-By
|
|
||||||
isPoweredBy :: Header -> Bool
|
isPoweredBy :: Header -> Bool
|
||||||
isPoweredBy (name, _) = CI.mk name == CI.mk "X-Powered-By"
|
isPoweredBy (name, _) = CI.mk name == CI.mk "X-Powered-By"
|
||||||
|
|
||||||
-- | Check if header is Server
|
|
||||||
isServerHeader :: Header -> Bool
|
isServerHeader :: Header -> Bool
|
||||||
isServerHeader (name, _) = CI.mk name == CI.mk "Server"
|
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) []
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Proxy.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
|
||||||
module Aenebris.Proxy
|
module Aenebris.Proxy
|
||||||
( ProxyState(..)
|
( ProxyState(..)
|
||||||
|
|
@ -13,13 +17,24 @@ module Aenebris.Proxy
|
||||||
import Aenebris.Backend
|
import Aenebris.Backend
|
||||||
import Aenebris.Config
|
import Aenebris.Config
|
||||||
import Aenebris.Connection
|
import Aenebris.Connection
|
||||||
|
( ConnectionType(..)
|
||||||
|
, defaultTimeoutConfig
|
||||||
|
, detectConnectionType
|
||||||
|
, microsPerSecond
|
||||||
|
, tcUpstreamReadSeconds
|
||||||
|
)
|
||||||
import Aenebris.HealthCheck
|
import Aenebris.HealthCheck
|
||||||
import Aenebris.LoadBalancer
|
import Aenebris.LoadBalancer
|
||||||
import Aenebris.TLS
|
import Aenebris.TLS
|
||||||
import Aenebris.Tunnel
|
import Aenebris.Tunnel
|
||||||
import Aenebris.Middleware.Security
|
import Aenebris.Middleware.Security
|
||||||
import Aenebris.Middleware.Redirect
|
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.EarlyData (earlyDataGuard)
|
||||||
import Aenebris.DDoS.MemoryShed
|
import Aenebris.DDoS.MemoryShed
|
||||||
( MemoryShed
|
( MemoryShed
|
||||||
|
|
@ -66,7 +81,13 @@ import Aenebris.Geo
|
||||||
)
|
)
|
||||||
import Control.Concurrent.STM (TVar, newTVarIO)
|
import Control.Concurrent.STM (TVar, newTVarIO)
|
||||||
import Control.Concurrent.Async (Async, async, waitAnyCancel)
|
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.Function ((&))
|
||||||
import Data.List (sortBy)
|
import Data.List (sortBy)
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
|
|
@ -76,12 +97,16 @@ import Data.Ord (comparing)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as TE
|
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 qualified Network.HTTP.Client as HTTP
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
import Data.ByteString.Builder (byteString)
|
|
||||||
import Control.Monad (unless)
|
|
||||||
import Network.Wai.Handler.Warp
|
import Network.Wai.Handler.Warp
|
||||||
( Settings
|
( Settings
|
||||||
, defaultSettings
|
, defaultSettings
|
||||||
|
|
@ -93,34 +118,80 @@ import Network.Wai.Handler.Warp
|
||||||
, setTimeout
|
, setTimeout
|
||||||
)
|
)
|
||||||
import Network.Wai.Handler.WarpTLS (runTLS)
|
import Network.Wai.Handler.WarpTLS (runTLS)
|
||||||
|
import System.Exit (exitFailure)
|
||||||
import System.IO (hPutStrLn, stderr)
|
import System.IO (hPutStrLn, stderr)
|
||||||
import Data.ByteString (ByteString)
|
import System.Timeout (timeout)
|
||||||
import qualified Data.ByteString as BS
|
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
memoryShedPollIntervalMicros :: Int
|
||||||
import qualified Data.ByteString.Lazy as LBS
|
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
|
data ProxyState = ProxyState
|
||||||
{ psConfig :: Config
|
{ psConfig :: !Config
|
||||||
, psLoadBalancers :: Map Text LoadBalancer -- upstream name -> load balancer
|
, psLoadBalancers :: !(Map Text LoadBalancer)
|
||||||
, psHealthCheckers :: [Async ()]
|
, psHealthCheckers :: ![Async ()]
|
||||||
, psManager :: Manager
|
, psManager :: !Manager
|
||||||
, psRateLimiter :: Maybe RateLimiter
|
, psRateLimiter :: !(Maybe RateLimiter)
|
||||||
, psMemoryShed :: Maybe MemoryShed
|
, psMemoryShed :: !(Maybe MemoryShed)
|
||||||
, psIPJail :: Maybe IPJail
|
, psIPJail :: !(Maybe IPJail)
|
||||||
, psConnLimiter :: Maybe ConnLimiter
|
, psConnLimiter :: !(Maybe ConnLimiter)
|
||||||
, psWafRuleSet :: TVar RuleSet
|
, psWafRuleSet :: !(TVar RuleSet)
|
||||||
, psGeo :: Maybe Geo
|
, psGeo :: !(Maybe Geo)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Initialize proxy state from config
|
|
||||||
initProxyState :: Config -> Manager -> IO ProxyState
|
initProxyState :: Config -> Manager -> IO ProxyState
|
||||||
initProxyState config manager = do
|
initProxyState config manager = do
|
||||||
-- Create load balancers for each upstream
|
|
||||||
lbs <- mapM createUpstreamLoadBalancer (configUpstreams config)
|
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)
|
checkers <- mapM startUpstreamHealthChecker (configUpstreams config)
|
||||||
|
|
||||||
rateLimiter <- case configRateLimit config >>= parseRateSpec of
|
rateLimiter <- case configRateLimit config >>= parseRateSpec of
|
||||||
|
|
@ -134,8 +205,9 @@ initProxyState config manager = do
|
||||||
ms <- newMemoryShed
|
ms <- newMemoryShed
|
||||||
let cfg = MemoryShedConfig
|
let cfg = MemoryShedConfig
|
||||||
{ mscHeapBudgetBytes = fromInteger budgetBytes
|
{ mscHeapBudgetBytes = fromInteger budgetBytes
|
||||||
, mscHighWaterFraction = fromMaybe defaultHighWaterFraction (ddos >>= ddosMemoryShedHighWater)
|
, mscHighWaterFraction = fromMaybe defaultHighWaterFraction
|
||||||
, mscPollIntervalMicros = 1000000
|
(ddos >>= ddosMemoryShedHighWater)
|
||||||
|
, mscPollIntervalMicros = memoryShedPollIntervalMicros
|
||||||
}
|
}
|
||||||
_ <- startMemoryShedPoller cfg ms
|
_ <- startMemoryShedPoller cfg ms
|
||||||
pure (Just ms)
|
pure (Just ms)
|
||||||
|
|
@ -162,84 +234,83 @@ initProxyState config manager = do
|
||||||
Nothing -> pure Nothing
|
Nothing -> pure Nothing
|
||||||
|
|
||||||
return ProxyState
|
return ProxyState
|
||||||
{ psConfig = config
|
{ psConfig = config
|
||||||
, psLoadBalancers = lbMap
|
, psLoadBalancers = lbMap
|
||||||
, psHealthCheckers = checkers
|
, psHealthCheckers = checkers
|
||||||
, psManager = manager
|
, psManager = manager
|
||||||
, psRateLimiter = rateLimiter
|
, psRateLimiter = rateLimiter
|
||||||
, psMemoryShed = memShed
|
, psMemoryShed = memShed
|
||||||
, psIPJail = ipJail
|
, psIPJail = ipJail
|
||||||
, psConnLimiter = connLimiter
|
, psConnLimiter = connLimiter
|
||||||
, psWafRuleSet = wafVar
|
, psWafRuleSet = wafVar
|
||||||
, psGeo = geoHandle
|
, psGeo = geoHandle
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
-- Create load balancer for an upstream
|
|
||||||
createUpstreamLoadBalancer :: Upstream -> IO LoadBalancer
|
createUpstreamLoadBalancer :: Upstream -> IO LoadBalancer
|
||||||
createUpstreamLoadBalancer upstream = do
|
createUpstreamLoadBalancer upstream = do
|
||||||
-- Convert Config Servers to RuntimeBackends
|
|
||||||
backends <- zipWithM createRuntimeBackend [0..] (upstreamServers upstream)
|
backends <- zipWithM createRuntimeBackend [0..] (upstreamServers upstream)
|
||||||
|
|
||||||
-- Determine strategy (for now, use weighted if weights differ, else round-robin)
|
|
||||||
let weights = map serverWeight (upstreamServers upstream)
|
let weights = map serverWeight (upstreamServers upstream)
|
||||||
strategy = case weights of
|
strategy = case weights of
|
||||||
[] -> RoundRobin -- No backends, shouldn't happen but be safe
|
[] -> RoundRobin
|
||||||
(w:ws) -> if all (== w) ws
|
(w:ws)
|
||||||
then RoundRobin
|
| all (== w) ws -> RoundRobin
|
||||||
else WeightedRoundRobin
|
| otherwise -> WeightedRoundRobin
|
||||||
|
|
||||||
createLoadBalancer strategy backends
|
createLoadBalancer strategy backends
|
||||||
|
|
||||||
-- Start health checker for an upstream
|
|
||||||
startUpstreamHealthChecker :: Upstream -> IO (Async ())
|
startUpstreamHealthChecker :: Upstream -> IO (Async ())
|
||||||
startUpstreamHealthChecker upstream = do
|
startUpstreamHealthChecker upstream = do
|
||||||
backends <- zipWithM createRuntimeBackend [0..] (upstreamServers upstream)
|
backends <- zipWithM createRuntimeBackend [0..] (upstreamServers upstream)
|
||||||
|
|
||||||
-- Use health check config from upstream, or defaults
|
|
||||||
let hcConfig = case upstreamHealthCheck upstream of
|
let hcConfig = case upstreamHealthCheck upstream of
|
||||||
Just hc -> defaultHealthCheckConfig
|
Just hc -> defaultHealthCheckConfig
|
||||||
{ hcInterval = 10 -- TODO: parse interval from config
|
{ hcEndpoint = healthCheckPath hc
|
||||||
, hcEndpoint = healthCheckPath hc
|
|
||||||
}
|
}
|
||||||
Nothing -> defaultHealthCheckConfig
|
Nothing -> defaultHealthCheckConfig
|
||||||
|
|
||||||
startHealthChecker manager hcConfig backends
|
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 -> IO ()
|
||||||
startProxy ProxyState{..} = do
|
startProxy ProxyState{..} = do
|
||||||
putStrLn $ "Starting Ᾰenebris reverse proxy"
|
putStrLn "Starting Aenebris reverse proxy"
|
||||||
putStrLn $ "Loaded " ++ show (length $ configUpstreams psConfig) ++ " upstream(s)"
|
putStrLn $ "Loaded " ++ show (length (configUpstreams psConfig)) ++ " upstream(s)"
|
||||||
putStrLn $ "Loaded " ++ show (length $ configRoutes psConfig) ++ " route(s)"
|
putStrLn $ "Loaded " ++ show (length (configRoutes psConfig)) ++ " route(s)"
|
||||||
putStrLn $ "Health checking enabled for all upstreams"
|
putStrLn "Health checking enabled for all upstreams"
|
||||||
|
|
||||||
case configListen psConfig of
|
case configListen psConfig of
|
||||||
[] -> error "No listen ports configured"
|
[] -> do
|
||||||
|
hPutStrLn stderr "ERROR: No listen ports configured"
|
||||||
|
exitFailure
|
||||||
listenConfigs -> do
|
listenConfigs -> do
|
||||||
case psRateLimiter of
|
case psRateLimiter of
|
||||||
Just _ -> putStrLn "Rate limiting enabled"
|
Just _ -> putStrLn "Rate limiting enabled"
|
||||||
Nothing -> pure ()
|
Nothing -> pure ()
|
||||||
|
|
||||||
putStrLn "WAF enabled (Phase 1: paranoia level 2, default rule pack)"
|
putStrLn "WAF enabled (Phase 1: paranoia level 2, default rule pack)"
|
||||||
case buildHoneypotConfig (configHoneypot psConfig) of
|
case buildHoneypotConfig (configHoneypot psConfig) of
|
||||||
Just hp -> putStrLn $ "Honeypot enabled (" ++ show (length (hpPatterns hp))
|
Just hp -> putStrLn $
|
||||||
++ " trap patterns, action=" ++ show (hpAction hp) ++ ")"
|
"Honeypot enabled ("
|
||||||
|
++ show (length (hpPatterns hp))
|
||||||
|
++ " trap patterns, action="
|
||||||
|
++ show (hpAction hp)
|
||||||
|
++ ")"
|
||||||
Nothing -> pure ()
|
Nothing -> pure ()
|
||||||
case psGeo of
|
case psGeo of
|
||||||
Just g ->
|
Just g ->
|
||||||
let gc = geoConfig g
|
let gc = geoConfig g
|
||||||
parts = [ "country_db=" ++ maybe "off" (const "on") (gcCountryDb gc)
|
parts =
|
||||||
, "asn_db=" ++ maybe "off" (const "on") (gcAsnDb gc)
|
[ "country_db=" ++ maybe "off" (const "on") (gcCountryDb gc)
|
||||||
, "blocked=" ++ show (length (gcBlockedCountries gc))
|
, "asn_db=" ++ maybe "off" (const "on") (gcAsnDb gc)
|
||||||
, "flagged_asns=" ++ show (length (gcFlaggedAsns gc))
|
, "blocked=" ++ show (length (gcBlockedCountries gc))
|
||||||
]
|
, "flagged_asns=" ++ show (length (gcFlaggedAsns gc))
|
||||||
|
]
|
||||||
in putStrLn $ "Geo/ASN enabled (" ++ unwords parts ++ ")"
|
in putStrLn $ "Geo/ASN enabled (" ++ unwords parts ++ ")"
|
||||||
Nothing -> pure ()
|
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
|
_ <- waitAnyCancel servers
|
||||||
|
|
||||||
putStrLn "All servers stopped"
|
putStrLn "All servers stopped"
|
||||||
|
|
||||||
launchServer
|
launchServer
|
||||||
|
|
@ -254,260 +325,253 @@ launchServer
|
||||||
-> Maybe Geo
|
-> Maybe Geo
|
||||||
-> ListenConfig
|
-> ListenConfig
|
||||||
-> IO (Async ())
|
-> IO (Async ())
|
||||||
launchServer config loadBalancers manager mRateLimiter mMemShed mIPJail mConnLim wafVar mGeo listenConfig = async $ do
|
launchServer config loadBalancers manager mRateLimiter mMemShed mIPJail mConnLim wafVar mGeo listenConfig =
|
||||||
let port = listenPort listenConfig
|
async $ do
|
||||||
shouldRedirect = fromMaybe False (listenRedirectHTTPS listenConfig)
|
let port = listenPort listenConfig
|
||||||
ddosCfg = fromMaybe defaultDDoSConfig (configDDoS config)
|
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
|
jailedApp = case mIPJail of
|
||||||
then earlyDataGuard securedApp
|
Just j -> ipJailMiddleware j geoApp
|
||||||
else securedApp
|
Nothing -> geoApp
|
||||||
|
|
||||||
mHoneypotCfg = buildHoneypotConfig (configHoneypot config)
|
shedApp = case mMemShed of
|
||||||
|
Just ms -> memoryShedMiddleware ms jailedApp
|
||||||
|
Nothing -> jailedApp
|
||||||
|
|
||||||
honeypotApp = case mHoneypotCfg of
|
limitedApp = case mRateLimiter of
|
||||||
Just hp -> honeypotMiddleware hp mIPJail earlyDataApp
|
Just rl -> rateLimitMiddleware rl shedApp
|
||||||
Nothing -> earlyDataApp
|
Nothing -> shedApp
|
||||||
|
|
||||||
geoApp = case mGeo of
|
warpSettings = applyDDoSSettings ddosCfg mConnLim
|
||||||
Just g -> geoMiddleware g mIPJail honeypotApp
|
(defaultSettings & setPort port)
|
||||||
Nothing -> honeypotApp
|
|
||||||
|
|
||||||
jailedApp = case mIPJail of
|
case listenTLS listenConfig of
|
||||||
Just j -> ipJailMiddleware j geoApp
|
Nothing -> do
|
||||||
Nothing -> geoApp
|
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 tlsConfig -> do
|
||||||
Just ms -> memoryShedMiddleware ms jailedApp
|
let isSNI = case tlsSNI tlsConfig of
|
||||||
Nothing -> jailedApp
|
Just domains -> not (null domains)
|
||||||
|
Nothing -> False
|
||||||
limitedApp = case mRateLimiter of
|
if isSNI
|
||||||
Just rl -> rateLimitMiddleware rl shedApp
|
then launchHTTPSWithSNI port tlsConfig limitedApp
|
||||||
Nothing -> shedApp
|
else launchHTTPS port tlsConfig limitedApp
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
applyDDoSSettings :: DDoSConfig -> Maybe ConnLimiter -> Settings -> Settings
|
applyDDoSSettings :: DDoSConfig -> Maybe ConnLimiter -> Settings -> Settings
|
||||||
applyDDoSSettings ddos mConnLim s0 =
|
applyDDoSSettings ddos mConnLim s0 =
|
||||||
let s1 = case ddosMaxHeaderBytes ddos of
|
let s1Inner = case ddosSlowlorisSeconds ddos of
|
||||||
Just n -> setMaxTotalHeaderLength n s1Inner
|
Just n -> setTimeout n s0
|
||||||
Nothing -> s1Inner
|
|
||||||
s1Inner = case ddosSlowlorisSeconds ddos of
|
|
||||||
Just n -> setTimeout n s0
|
|
||||||
Nothing -> s0
|
Nothing -> s0
|
||||||
|
s1 = case ddosMaxHeaderBytes ddos of
|
||||||
|
Just n -> setMaxTotalHeaderLength n s1Inner
|
||||||
|
Nothing -> s1Inner
|
||||||
s2 = case mConnLim of
|
s2 = case mConnLim of
|
||||||
Just cl -> setOnClose (connLimitOnClose cl) (setOnOpen (connLimitOnOpen cl) s1)
|
Just cl -> setOnClose (connLimitOnClose cl)
|
||||||
|
(setOnOpen (connLimitOnOpen cl) s1)
|
||||||
Nothing -> s1
|
Nothing -> s1
|
||||||
in s2
|
in s2
|
||||||
|
|
||||||
-- | Launch HTTPS server with single certificate
|
|
||||||
launchHTTPS :: Int -> TLSConfig -> Application -> IO ()
|
launchHTTPS :: Int -> TLSConfig -> Application -> IO ()
|
||||||
launchHTTPS port tlsConfig app = do
|
launchHTTPS port tlsConfig app =
|
||||||
case (tlsCert tlsConfig, tlsKey tlsConfig) of
|
case (tlsCert tlsConfig, tlsKey tlsConfig) of
|
||||||
(Just certFile, Just keyFile) -> do
|
(Just certFile, Just keyFile) -> do
|
||||||
-- Load TLS settings
|
|
||||||
tlsResult <- createTLSSettings certFile keyFile
|
tlsResult <- createTLSSettings certFile keyFile
|
||||||
case tlsResult of
|
case tlsResult of
|
||||||
Left err -> do
|
Left err -> do
|
||||||
hPutStrLn stderr $ "ERROR: Failed to load TLS certificate"
|
hPutStrLn stderr "ERROR: Failed to load TLS certificate"
|
||||||
hPutStrLn stderr $ " " ++ show err
|
hPutStrLn stderr $ " " ++ show err
|
||||||
error "TLS configuration error"
|
exitFailure
|
||||||
|
|
||||||
Right tlsSettings -> do
|
Right tlsSettings -> do
|
||||||
let warpSettings = defaultSettings & setPort port
|
let warpSettings = defaultSettings & setPort port
|
||||||
putStrLn $ "✓ HTTPS server listening on :" ++ show port
|
putStrLn $ "* HTTPS server listening on :" ++ show port
|
||||||
putStrLn $ " ├─ Certificate: " ++ certFile
|
putStrLn $ " Certificate: " ++ certFile
|
||||||
putStrLn $ " ├─ TLS 1.2 + TLS 1.3 enabled"
|
putStrLn " TLS 1.2 + TLS 1.3 enabled"
|
||||||
putStrLn $ " ├─ HTTP/2 enabled (ALPN)"
|
putStrLn " HTTP/2 enabled (ALPN)"
|
||||||
putStrLn $ " └─ Strong cipher suites enforced"
|
putStrLn " Strong cipher suites enforced"
|
||||||
runTLS tlsSettings warpSettings app
|
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 :: Int -> TLSConfig -> Application -> IO ()
|
||||||
launchHTTPSWithSNI port tlsConfig app = do
|
launchHTTPSWithSNI port tlsConfig app =
|
||||||
case (tlsSNI tlsConfig, tlsDefaultCert tlsConfig, tlsDefaultKey tlsConfig) of
|
case (tlsSNI tlsConfig, tlsDefaultCert tlsConfig, tlsDefaultKey tlsConfig) of
|
||||||
(Just sniDomains, Just defaultCert, Just defaultKey) -> do
|
(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]
|
let domainList = [(sniDomain d, sniCert d, sniKey d) | d <- sniDomains]
|
||||||
|
|
||||||
-- Load SNI TLS settings
|
|
||||||
tlsResult <- createSNISettings domainList defaultCert defaultKey
|
tlsResult <- createSNISettings domainList defaultCert defaultKey
|
||||||
case tlsResult of
|
case tlsResult of
|
||||||
Left err -> do
|
Left err -> do
|
||||||
hPutStrLn stderr $ "ERROR: Failed to load SNI certificates"
|
hPutStrLn stderr "ERROR: Failed to load SNI certificates"
|
||||||
hPutStrLn stderr $ " " ++ show err
|
hPutStrLn stderr $ " " ++ show err
|
||||||
error "SNI configuration error"
|
exitFailure
|
||||||
|
|
||||||
Right tlsSettings -> do
|
Right tlsSettings -> do
|
||||||
let warpSettings = defaultSettings & setPort port
|
let warpSettings = defaultSettings & setPort port
|
||||||
putStrLn $ "✓ HTTPS server with SNI listening on :" ++ show port
|
putStrLn $ "* HTTPS server with SNI listening on :" ++ show port
|
||||||
putStrLn $ " ├─ SNI domains: " ++ show (length sniDomains) ++ " configured"
|
putStrLn $ " SNI domains: " ++ show (length sniDomains) ++ " configured"
|
||||||
mapM_ (\d -> putStrLn $ " │ • " ++ T.unpack (sniDomain d) ++ " -> " ++ sniCert d) sniDomains
|
mapM_
|
||||||
putStrLn $ " ├─ Default certificate: " ++ defaultCert
|
(\d -> putStrLn $
|
||||||
putStrLn $ " ├─ TLS 1.2 + TLS 1.3 enabled"
|
" " ++ T.unpack (sniDomain d) ++ " -> " ++ sniCert d)
|
||||||
putStrLn $ " ├─ HTTP/2 enabled (ALPN)"
|
sniDomains
|
||||||
putStrLn $ " └─ Strong cipher suites enforced"
|
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
|
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 -> Map Text LoadBalancer -> Manager -> Application
|
||||||
proxyApp config loadBalancers manager req respond = do
|
proxyApp config loadBalancers manager req respond = do
|
||||||
logRequest req
|
let hostHeader = lookup "Host" (requestHeaders req)
|
||||||
|
|
||||||
let hostHeader = lookup "Host" (requestHeaders req)
|
|
||||||
requestPath = rawPathInfo req
|
requestPath = rawPathInfo req
|
||||||
headers = requestHeaders req
|
headers = requestHeaders req
|
||||||
connType = detectConnectionType headers
|
connType = detectConnectionType headers
|
||||||
|
|
||||||
case selectRoute config hostHeader requestPath of
|
case selectRoute config hostHeader requestPath of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
hPutStrLn stderr $ "ERROR: No route found for request"
|
hPutStrLn stderr "ERROR: No route found for request"
|
||||||
respond $ responseLBS
|
respond $ responseLBS
|
||||||
status404
|
status404
|
||||||
[("Content-Type", "text/plain")]
|
[(hContentType, contentTypePlain)]
|
||||||
"Not Found: No route configured for this host/path"
|
bodyNotFound
|
||||||
|
|
||||||
Just (upstreamName, _pathRoute) -> do
|
Just (upstreamName, _pathRoute) ->
|
||||||
case Map.lookup upstreamName loadBalancers of
|
case Map.lookup upstreamName loadBalancers of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
hPutStrLn stderr $ "ERROR: Load balancer not found: " ++ T.unpack upstreamName
|
hPutStrLn stderr $
|
||||||
|
"ERROR: Load balancer not found: " ++ T.unpack upstreamName
|
||||||
respond $ responseLBS
|
respond $ responseLBS
|
||||||
status500
|
status500
|
||||||
[("Content-Type", "text/plain")]
|
[(hContentType, contentTypePlain)]
|
||||||
"Internal Server Error: Upstream configuration error"
|
bodyUpstreamMisconfigured
|
||||||
|
|
||||||
Just loadBalancer -> do
|
Just loadBalancer -> do
|
||||||
mBackend <- selectBackend loadBalancer
|
mBackend <- selectBackend loadBalancer
|
||||||
|
|
||||||
case mBackend of
|
case mBackend of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
hPutStrLn stderr $ "ERROR: No healthy backends available"
|
hPutStrLn stderr "ERROR: No healthy backends available"
|
||||||
respond $ responseLBS
|
respond $ responseLBS
|
||||||
status503
|
status503
|
||||||
[("Content-Type", "text/plain")]
|
[(hContentType, contentTypePlain)]
|
||||||
"Service Unavailable: No healthy backends available"
|
bodyNoHealthyBackends
|
||||||
|
|
||||||
Just backend -> do
|
Just backend -> case connType of
|
||||||
case connType of
|
WebSocket -> do
|
||||||
WebSocket -> do
|
hPutStrLn stderr "[WS] WebSocket upgrade detected"
|
||||||
hPutStrLn stderr $ "[WS] WebSocket upgrade detected"
|
handleWebSocketUpgrade req respond backend
|
||||||
handleWebSocketUpgrade req respond backend
|
_ ->
|
||||||
|
forwardRegular manager backend req respond
|
||||||
|
|
||||||
RegularHttp -> do
|
forwardRegular
|
||||||
result <- try $ trackConnection backend $
|
:: Manager
|
||||||
forwardRequest manager req (rbHost backend) respond
|
-> 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
|
handleWebSocketUpgrade
|
||||||
Left (err :: SomeException) -> do
|
:: Request
|
||||||
hPutStrLn stderr $ "ERROR: " ++ show err
|
-> (Response -> IO ResponseReceived)
|
||||||
respond $ responseLBS
|
-> RuntimeBackend
|
||||||
status502
|
-> IO ResponseReceived
|
||||||
[("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 req respond backend = do
|
handleWebSocketUpgrade req respond backend = do
|
||||||
let backendHost = rbHost backend
|
let backendHost = rbHost backend
|
||||||
backupResponse = responseLBS
|
backupResponse = responseLBS
|
||||||
status502
|
status502
|
||||||
[("Content-Type", "text/plain")]
|
[(hContentType, contentTypePlain)]
|
||||||
"WebSocket upgrade failed"
|
bodyWebSocketUpgradeFailed
|
||||||
|
|
||||||
respond $ responseRaw (wsHandler req backendHost) backupResponse
|
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
|
wsHandler req backendHost recv send = do
|
||||||
hPutStrLn stderr $ "[WS] Starting WebSocket tunnel to " ++ T.unpack backendHost
|
hPutStrLn stderr $ "[WS] Starting WebSocket tunnel to " ++ T.unpack backendHost
|
||||||
tunnelWebSocket req backendHost send recv
|
tunnelWebSocket req backendHost send recv
|
||||||
|
|
||||||
-- | Select a route based on Host header and path
|
selectRoute
|
||||||
selectRoute :: Config -> Maybe BS.ByteString -> BS.ByteString -> Maybe (Text, PathRoute)
|
:: Config
|
||||||
selectRoute config hostHeader requestPath =
|
-> Maybe BS.ByteString
|
||||||
case hostHeader of
|
-> BS.ByteString
|
||||||
Nothing -> Nothing -- No Host header, can't route
|
-> Maybe (Text, PathRoute)
|
||||||
Just host -> do
|
selectRoute config hostHeader requestPath = case hostHeader of
|
||||||
-- Find route matching this host
|
Nothing -> Nothing
|
||||||
let hostText = TE.decodeUtf8 host
|
Just host -> do
|
||||||
matchingRoutes = filter (\r -> routeHost r == hostText) (configRoutes config)
|
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 :: Text -> Text -> Bool
|
||||||
pathMatches pattern requestPath =
|
pathMatches pattern requestPath =
|
||||||
pattern == "/" || T.isPrefixOf pattern requestPath
|
pattern == "/" || T.isPrefixOf pattern requestPath
|
||||||
|
|
||||||
-- | Select an upstream for a request (exported for testing)
|
selectUpstream
|
||||||
selectUpstream :: Config -> Maybe BS.ByteString -> BS.ByteString -> Maybe Text
|
:: Config -> Maybe BS.ByteString -> BS.ByteString -> Maybe Text
|
||||||
selectUpstream config hostHeader requestPath =
|
selectUpstream config hostHeader requestPath =
|
||||||
fmap fst $ selectRoute config hostHeader requestPath
|
fst <$> selectRoute config hostHeader requestPath
|
||||||
|
|
||||||
-- | Forward request to backend server with streaming support
|
forwardRequest
|
||||||
forwardRequest :: Manager -> Request -> Text -> (Response -> IO ResponseReceived) -> IO ResponseReceived
|
:: Manager
|
||||||
|
-> Request
|
||||||
|
-> Text
|
||||||
|
-> (Response -> IO ResponseReceived)
|
||||||
|
-> IO ResponseReceived
|
||||||
forwardRequest manager clientReq backendHost respond = do
|
forwardRequest manager clientReq backendHost respond = do
|
||||||
let backendUrl = "http://" ++ T.unpack backendHost ++
|
let backendUrl = httpScheme ++ T.unpack backendHost
|
||||||
BS8.unpack (rawPathInfo clientReq) ++
|
++ BS8.unpack (rawPathInfo clientReq)
|
||||||
BS8.unpack (rawQueryString clientReq)
|
++ BS8.unpack (rawQueryString clientReq)
|
||||||
|
|
||||||
initReq <- parseRequest backendUrl
|
initReq <- parseRequest backendUrl
|
||||||
|
|
||||||
|
|
@ -521,47 +585,53 @@ forwardRequest manager clientReq backendHost respond = do
|
||||||
|
|
||||||
backendReq = initReq
|
backendReq = initReq
|
||||||
{ HTTP.method = requestMethod clientReq
|
{ HTTP.method = requestMethod clientReq
|
||||||
, HTTP.requestHeaders = filterHeaders (requestHeaders clientReq)
|
, HTTP.requestHeaders = filterRequestHeaders (requestHeaders clientReq)
|
||||||
, HTTP.requestBody = streamingBody
|
, HTTP.requestBody = streamingBody
|
||||||
}
|
}
|
||||||
|
|
||||||
withResponse backendReq manager $ \backendResponse -> do
|
upstreamMicros = tcUpstreamReadSeconds defaultTimeoutConfig
|
||||||
let status = HTTP.responseStatus backendResponse
|
* microsPerSecond
|
||||||
headers = HTTP.responseHeaders backendResponse
|
|
||||||
bodyReader = HTTP.responseBody backendResponse
|
|
||||||
|
|
||||||
if shouldStreamResponse headers
|
mResult <- timeout upstreamMicros $
|
||||||
then do
|
withResponse backendReq manager $ \backendResponse -> do
|
||||||
hPutStrLn stderr "[STREAM] Streaming response detected"
|
let status = HTTP.responseStatus backendResponse
|
||||||
respond $ responseStream status (filterResponseHeaders headers) $ \write flush -> do
|
headers = HTTP.responseHeaders backendResponse
|
||||||
let loop = do
|
bodyReader = HTTP.responseBody backendResponse
|
||||||
chunk <- brRead bodyReader
|
if shouldStreamResponse headers
|
||||||
unless (BS.null chunk) $ do
|
then do
|
||||||
write (byteString chunk)
|
hPutStrLn stderr "[STREAM] Streaming response detected"
|
||||||
flush
|
respond $ responseStream status (filterResponseHeaders headers) $
|
||||||
loop
|
\write flush -> do
|
||||||
loop
|
let loop = do
|
||||||
else do
|
chunk <- brRead bodyReader
|
||||||
body <- readFullBody bodyReader
|
unless (BS.null chunk) $ do
|
||||||
respond $ responseLBS status (filterResponseHeaders headers) body
|
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 :: [(HeaderName, BS.ByteString)] -> Bool
|
||||||
shouldStreamResponse headers =
|
shouldStreamResponse headers = isSSE || isChunkedWithoutLength
|
||||||
isSSE || isChunkedWithoutLength
|
|
||||||
where
|
where
|
||||||
isSSE = case lookup "Content-Type" headers of
|
isSSE = case lookup "Content-Type" headers of
|
||||||
Just ct -> "text/event-stream" `BS.isInfixOf` ct
|
Just ct -> eventStreamContentType `BS.isInfixOf` ct
|
||||||
Nothing -> False
|
Nothing -> False
|
||||||
|
isChunkedWithoutLength = hasChunkedEncoding && not hasContentLength
|
||||||
isChunkedWithoutLength =
|
|
||||||
hasChunkedEncoding && not hasContentLength
|
|
||||||
|
|
||||||
hasChunkedEncoding = case lookup "Transfer-Encoding" headers of
|
hasChunkedEncoding = case lookup "Transfer-Encoding" headers of
|
||||||
Just te -> "chunked" `BS.isInfixOf` te
|
Just te -> chunkedEncoding `BS.isInfixOf` te
|
||||||
Nothing -> False
|
Nothing -> False
|
||||||
|
|
||||||
hasContentLength = case lookup "Content-Length" headers of
|
hasContentLength = case lookup "Content-Length" headers of
|
||||||
Just _ -> True
|
Just _ -> True
|
||||||
Nothing -> False
|
Nothing -> False
|
||||||
|
|
||||||
readFullBody :: HTTP.BodyReader -> IO LBS.ByteString
|
readFullBody :: HTTP.BodyReader -> IO LBS.ByteString
|
||||||
|
|
@ -570,45 +640,17 @@ readFullBody bodyReader = LBS.fromChunks <$> go
|
||||||
go = do
|
go = do
|
||||||
chunk <- brRead bodyReader
|
chunk <- brRead bodyReader
|
||||||
if BS.null chunk
|
if BS.null chunk
|
||||||
then return []
|
then pure []
|
||||||
else do
|
else do
|
||||||
rest <- go
|
rest <- go
|
||||||
return (chunk : rest)
|
pure (chunk : rest)
|
||||||
|
|
||||||
filterResponseHeaders :: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)]
|
filterResponseHeaders
|
||||||
filterResponseHeaders = filter (\(name, _) -> name `notElem` hopByHopHeaders)
|
:: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)]
|
||||||
where
|
filterResponseHeaders =
|
||||||
hopByHopHeaders =
|
filter (\(name, _) -> name `notElem` hopByHopResponseHeaders)
|
||||||
[ "Transfer-Encoding"
|
|
||||||
, "Connection"
|
|
||||||
, "Keep-Alive"
|
|
||||||
]
|
|
||||||
|
|
||||||
-- | Filter headers for regular HTTP (remove hop-by-hop headers)
|
filterRequestHeaders
|
||||||
filterHeaders :: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)]
|
:: [(HeaderName, BS.ByteString)] -> [(HeaderName, BS.ByteString)]
|
||||||
filterHeaders headers = filter (\(name, _) -> name `notElem` hopByHopHeaders) headers
|
filterRequestHeaders =
|
||||||
where
|
filter (\(name, _) -> name `notElem` hopByHopRequestHeaders)
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import Control.Concurrent.STM
|
||||||
, readTVar
|
, readTVar
|
||||||
, writeTVar
|
, writeTVar
|
||||||
)
|
)
|
||||||
|
import Aenebris.Net.IP (sockAddrToIPBytes)
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
import qualified Data.ByteString.Char8 as BS8
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
|
|
@ -38,12 +39,6 @@ import qualified Data.Text as T
|
||||||
import qualified Data.Text.Read as TR
|
import qualified Data.Text.Read as TR
|
||||||
import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime)
|
import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime)
|
||||||
import Network.HTTP.Types (status429)
|
import Network.HTTP.Types (status429)
|
||||||
import Network.Socket
|
|
||||||
( HostAddress6
|
|
||||||
, SockAddr(..)
|
|
||||||
, hostAddress6ToTuple
|
|
||||||
, hostAddressToTuple
|
|
||||||
)
|
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
( Middleware
|
( Middleware
|
||||||
, Request
|
, Request
|
||||||
|
|
@ -51,8 +46,6 @@ import Network.Wai
|
||||||
, remoteHost
|
, remoteHost
|
||||||
, responseLBS
|
, responseLBS
|
||||||
)
|
)
|
||||||
import Numeric (showHex)
|
|
||||||
import Text.Printf (printf)
|
|
||||||
|
|
||||||
secondsPerMinute :: Double
|
secondsPerMinute :: Double
|
||||||
secondsPerMinute = 60
|
secondsPerMinute = 60
|
||||||
|
|
@ -164,23 +157,7 @@ rateLimitMiddleware rl app req respond = do
|
||||||
intBS n = BS8.pack (show n)
|
intBS n = BS8.pack (show n)
|
||||||
|
|
||||||
clientIPKey :: Request -> ByteString
|
clientIPKey :: Request -> ByteString
|
||||||
clientIPKey req = case remoteHost req of
|
clientIPKey = sockAddrToIPBytes . remoteHost
|
||||||
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
|
|
||||||
|
|
||||||
pathClassKey :: Request -> ByteString
|
pathClassKey :: Request -> ByteString
|
||||||
pathClassKey req =
|
pathClassKey req =
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
TLS.hs
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
{-# LANGUAGE RecordWildCards #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
|
@ -8,23 +12,27 @@ module Aenebris.TLS
|
||||||
, createSNISettings
|
, createSNISettings
|
||||||
, validateCertificate
|
, validateCertificate
|
||||||
, CertificateError(..)
|
, CertificateError(..)
|
||||||
|
, strongCipherSuites
|
||||||
) where
|
) 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 Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
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 (SignedCertificate)
|
||||||
import Data.X509.File (readSignedObject)
|
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 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
|
data CertificateError
|
||||||
= CertFileNotFound FilePath
|
= CertFileNotFound FilePath
|
||||||
| KeyFileNotFound FilePath
|
| KeyFileNotFound FilePath
|
||||||
|
|
@ -32,130 +40,128 @@ data CertificateError
|
||||||
| InvalidKey FilePath String
|
| InvalidKey FilePath String
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | Create TLS settings for a single certificate (non-SNI)
|
createTLSSettings
|
||||||
createTLSSettings :: FilePath -> FilePath -> IO (Either CertificateError TLSSettings)
|
:: FilePath
|
||||||
|
-> FilePath
|
||||||
|
-> IO (Either CertificateError TLSSettings)
|
||||||
createTLSSettings certFile keyFile = do
|
createTLSSettings certFile keyFile = do
|
||||||
-- Validate files exist
|
|
||||||
certExists <- doesFileExist certFile
|
certExists <- doesFileExist certFile
|
||||||
keyExists <- doesFileExist keyFile
|
keyExists <- doesFileExist keyFile
|
||||||
|
|
||||||
if not certExists
|
if not certExists
|
||||||
then return $ Left (CertFileNotFound certFile)
|
then pure (Left (CertFileNotFound certFile))
|
||||||
else if not keyExists
|
else if not keyExists
|
||||||
then return $ Left (KeyFileNotFound keyFile)
|
then pure (Left (KeyFileNotFound keyFile))
|
||||||
else do
|
else do
|
||||||
-- Try to load the credential to validate it
|
|
||||||
result <- try $ TLS.credentialLoadX509 certFile keyFile
|
result <- try $ TLS.credentialLoadX509 certFile keyFile
|
||||||
case result of
|
case result of
|
||||||
Left (err :: SomeException) ->
|
Left (err :: SomeException) ->
|
||||||
return $ Left (InvalidCertificate certFile (show err))
|
pure (Left (InvalidCertificate certFile (show err)))
|
||||||
|
|
||||||
Right (Left 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
|
configureTLS :: FilePath -> FilePath -> TLSSettings
|
||||||
-- Create TLS settings with strong security
|
configureTLS certFile keyFile = (tlsSettings certFile keyFile)
|
||||||
let tlsConfig = (tlsSettings certFile keyFile)
|
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
|
||||||
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
|
, tlsCiphers = strongCipherSuites
|
||||||
, tlsCiphers = strongCipherSuites
|
, onInsecure = DenyInsecure httpsRequiredMessage
|
||||||
, onInsecure = DenyInsecure "This server requires HTTPS"
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $ Right tlsConfig
|
createSNISettings
|
||||||
|
:: [(Text, FilePath, FilePath)]
|
||||||
-- | Create TLS settings with SNI support for multiple domains
|
-> FilePath
|
||||||
createSNISettings :: [(Text, FilePath, FilePath)] -> FilePath -> FilePath -> IO (Either CertificateError TLSSettings)
|
-> FilePath
|
||||||
|
-> IO (Either CertificateError TLSSettings)
|
||||||
createSNISettings domains defaultCert defaultKey = do
|
createSNISettings domains defaultCert defaultKey = do
|
||||||
-- Validate default certificate
|
defaultCertOk <- doesFileExist defaultCert
|
||||||
defaultExists <- doesFileExist defaultCert
|
defaultKeyOk <- doesFileExist defaultKey
|
||||||
defaultKeyExists <- doesFileExist defaultKey
|
if not defaultCertOk
|
||||||
|
then pure (Left (CertFileNotFound defaultCert))
|
||||||
if not defaultExists
|
else if not defaultKeyOk
|
||||||
then return $ Left (CertFileNotFound defaultCert)
|
then pure (Left (KeyFileNotFound defaultKey))
|
||||||
else if not defaultKeyExists
|
|
||||||
then return $ Left (KeyFileNotFound defaultKey)
|
|
||||||
else do
|
else do
|
||||||
-- Validate all domain certificates exist
|
validations <- mapM validateDomainCert domains
|
||||||
validationResults <- mapM validateDomainCert domains
|
case sequence validations of
|
||||||
case sequence validationResults of
|
Left err -> pure (Left err)
|
||||||
Left err -> return $ Left err
|
Right _ -> pure (Right (configureSNI domains defaultCert defaultKey))
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ Right tlsConfig
|
configureSNI
|
||||||
where
|
:: [(Text, FilePath, FilePath)]
|
||||||
validateDomainCert :: (Text, FilePath, FilePath) -> IO (Either CertificateError ())
|
-> FilePath
|
||||||
validateDomainCert (domain, certFile, keyFile) = do
|
-> FilePath
|
||||||
certExists <- doesFileExist certFile
|
-> TLSSettings
|
||||||
keyExists <- doesFileExist keyFile
|
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
|
validateDomainCert
|
||||||
then return $ Left (CertFileNotFound certFile)
|
:: (Text, FilePath, FilePath) -> IO (Either CertificateError ())
|
||||||
else if not keyExists
|
validateDomainCert (_domain, certFile, keyFile) = do
|
||||||
then return $ Left (KeyFileNotFound keyFile)
|
certExists <- doesFileExist certFile
|
||||||
else return $ Right ()
|
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
|
||||||
sniCallback :: [(Text, FilePath, FilePath)] -> FilePath -> FilePath -> String -> IO TLS.Credentials
|
:: [(Text, FilePath, FilePath)]
|
||||||
sniCallback domains defaultCert defaultKey hostname = do
|
-> FilePath
|
||||||
let hostnameText = T.pack hostname
|
-> FilePath
|
||||||
-- Look up domain in map
|
-> 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]
|
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
|
credentialsOrDefault :: FilePath -> FilePath -> IO TLS.Credentials
|
||||||
Nothing -> do
|
credentialsOrDefault certFile keyFile = 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
|
|
||||||
result <- TLS.credentialLoadX509 certFile keyFile
|
result <- TLS.credentialLoadX509 certFile keyFile
|
||||||
case result of
|
case result of
|
||||||
Left err ->
|
Left err -> do
|
||||||
error $ "Failed to load certificate: " ++ err
|
hPutStrLn stderr $
|
||||||
|
"TLS: failed to load credential at "
|
||||||
|
<> certFile <> " (" <> err <> "); SNI handler returns empty credentials"
|
||||||
|
pure (TLS.Credentials [])
|
||||||
Right credential ->
|
Right credential ->
|
||||||
return $ TLS.Credentials [credential]
|
pure (TLS.Credentials [credential])
|
||||||
|
|
||||||
-- | Validate a certificate file (check if it's readable and valid)
|
validateCertificate
|
||||||
validateCertificate :: FilePath -> IO (Either CertificateError [SignedCertificate])
|
:: FilePath -> IO (Either CertificateError [SignedCertificate])
|
||||||
validateCertificate certFile = do
|
validateCertificate certFile = do
|
||||||
exists <- doesFileExist certFile
|
exists <- doesFileExist certFile
|
||||||
if not exists
|
if not exists
|
||||||
then return $ Left (CertFileNotFound certFile)
|
then pure (Left (CertFileNotFound certFile))
|
||||||
else do
|
else do
|
||||||
result <- try $ readSignedObject certFile
|
result <- try $ readSignedObject certFile
|
||||||
case result of
|
case result of
|
||||||
Left (err :: SomeException) ->
|
Left (err :: SomeException) ->
|
||||||
return $ Left (InvalidCertificate certFile (show err))
|
pure (Left (InvalidCertificate certFile (show err)))
|
||||||
Right certs ->
|
Right certs ->
|
||||||
return $ Right certs
|
pure (Right certs)
|
||||||
|
|
||||||
-- | Strong cipher suites for production (TLS 1.2 + TLS 1.3)
|
|
||||||
strongCipherSuites :: [TLS.Cipher]
|
strongCipherSuites :: [TLS.Cipher]
|
||||||
strongCipherSuites =
|
strongCipherSuites =
|
||||||
-- TLS 1.3 cipher suites (preferred)
|
[ Cipher.cipher13_AES_128_GCM_SHA256
|
||||||
[ Cipher.cipher_TLS13_AES128GCM_SHA256
|
, Cipher.cipher13_AES_256_GCM_SHA384
|
||||||
, Cipher.cipher_TLS13_AES256GCM_SHA384
|
, Cipher.cipher13_CHACHA20_POLY1305_SHA256
|
||||||
, Cipher.cipher_TLS13_CHACHA20POLY1305_SHA256
|
, Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||||
] ++
|
|
||||||
-- TLS 1.2 cipher suites (fallback, only ECDHE + AEAD)
|
|
||||||
[ Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
|
||||||
, Cipher.cipher_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
, Cipher.cipher_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||||
, Cipher.cipher_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
, Cipher.cipher_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||||
, Cipher.cipher_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
, Cipher.cipher_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,95 @@
|
||||||
|
{-
|
||||||
|
©AngelaMos | 2026
|
||||||
|
Tunnel.hs
|
||||||
|
-}
|
||||||
|
{-# LANGUAGE BangPatterns #-}
|
||||||
|
{-# LANGUAGE NumericUnderscores #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE RecordWildCards #-}
|
|
||||||
|
|
||||||
module Aenebris.Tunnel
|
module Aenebris.Tunnel
|
||||||
( tunnelWebSocket
|
( ConnectError(..)
|
||||||
|
, connectToBackend
|
||||||
|
, parseHostPort
|
||||||
|
, parseUpgradeStatus
|
||||||
|
, tunnelWebSocket
|
||||||
, streamResponse
|
, streamResponse
|
||||||
, bidirectionalCopy
|
, bidirectionalCopy
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Concurrent.Async (race_)
|
import Control.Concurrent.Async (race_)
|
||||||
import Control.Exception (SomeException, try, bracket)
|
import Control.Exception
|
||||||
|
( SomeException
|
||||||
|
, bracketOnError
|
||||||
|
, finally
|
||||||
|
, try
|
||||||
|
)
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
import qualified Data.ByteString.Char8 as BS8
|
||||||
import Data.CaseInsensitive (original)
|
import Data.CaseInsensitive (original)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Network.HTTP.Types (HeaderName)
|
|
||||||
import Network.Socket (Socket)
|
import Network.Socket (Socket)
|
||||||
import qualified Network.Socket as Socket
|
import qualified Network.Socket as Socket
|
||||||
import qualified Network.Socket.ByteString as SocketBS
|
import qualified Network.Socket.ByteString as SocketBS
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
|
( Request
|
||||||
|
, rawPathInfo
|
||||||
|
, rawQueryString
|
||||||
|
, requestHeaders
|
||||||
|
, requestMethod
|
||||||
|
)
|
||||||
import System.IO (hPutStrLn, stderr)
|
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
|
tunnelWebSocket
|
||||||
:: Request
|
:: Request
|
||||||
|
|
@ -31,39 +99,62 @@ tunnelWebSocket
|
||||||
-> IO ()
|
-> IO ()
|
||||||
tunnelWebSocket clientReq backendHost clientSend clientRecv = do
|
tunnelWebSocket clientReq backendHost clientSend clientRecv = do
|
||||||
hPutStrLn stderr $ "[WS] Initiating tunnel to " ++ T.unpack backendHost
|
hPutStrLn stderr $ "[WS] Initiating tunnel to " ++ T.unpack backendHost
|
||||||
|
let (host, port) = parseHostPort backendHost
|
||||||
result <- try $ do
|
outcome <- try $ do
|
||||||
let (host, port) = parseHostPort backendHost
|
eSock <- connectToBackend host port
|
||||||
|
case eSock of
|
||||||
bracket
|
Left err -> do
|
||||||
(connectToBackend host port)
|
hPutStrLn stderr $ "[WS] Connection failed: " ++ show err
|
||||||
Socket.close
|
clientSend badGatewayResponseLine
|
||||||
$ \backendSocket -> do
|
Right sock ->
|
||||||
sendUpgradeRequest backendSocket clientReq
|
runUpgrade clientReq clientSend clientRecv sock
|
||||||
upgradeResponse <- receiveUpgradeResponse backendSocket
|
`finally` Socket.close sock
|
||||||
|
case outcome of
|
||||||
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
|
|
||||||
Left (e :: SomeException) ->
|
Left (e :: SomeException) ->
|
||||||
hPutStrLn stderr $ "[WS] Tunnel error: " ++ show e
|
hPutStrLn stderr $ "[WS] Tunnel error: " ++ show e
|
||||||
Right () ->
|
Right () ->
|
||||||
hPutStrLn stderr "[WS] Tunnel closed"
|
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
|
bidirectionalCopy
|
||||||
:: IO ByteString
|
:: IO ByteString
|
||||||
-> (ByteString -> IO ())
|
-> (ByteString -> IO ())
|
||||||
|
|
@ -72,11 +163,9 @@ bidirectionalCopy
|
||||||
-> IO ()
|
-> IO ()
|
||||||
bidirectionalCopy clientRecv clientSend backendRecv backendSend = do
|
bidirectionalCopy clientRecv clientSend backendRecv backendSend = do
|
||||||
hPutStrLn stderr "[TUNNEL] Starting bidirectional copy"
|
hPutStrLn stderr "[TUNNEL] Starting bidirectional copy"
|
||||||
|
|
||||||
race_
|
race_
|
||||||
(copyLoop "client->backend" clientRecv backendSend)
|
(copyLoop "client->backend" clientRecv backendSend)
|
||||||
(copyLoop "backend->client" backendRecv clientSend)
|
(copyLoop "backend->client" backendRecv clientSend)
|
||||||
|
|
||||||
hPutStrLn stderr "[TUNNEL] Bidirectional copy ended"
|
hPutStrLn stderr "[TUNNEL] Bidirectional copy ended"
|
||||||
|
|
||||||
copyLoop :: String -> IO ByteString -> (ByteString -> IO ()) -> IO ()
|
copyLoop :: String -> IO ByteString -> (ByteString -> IO ()) -> IO ()
|
||||||
|
|
@ -112,56 +201,74 @@ parseHostPort hostPort =
|
||||||
[host, portStr] ->
|
[host, portStr] ->
|
||||||
case reads (T.unpack portStr) of
|
case reads (T.unpack portStr) of
|
||||||
[(port, "")] -> (T.unpack host, port)
|
[(port, "")] -> (T.unpack host, port)
|
||||||
_ -> (T.unpack host, 80)
|
_ -> (T.unpack host, defaultBackendPort)
|
||||||
[host] -> (T.unpack host, 80)
|
[host] -> (T.unpack host, defaultBackendPort)
|
||||||
_ -> (T.unpack hostPort, 80)
|
_ -> (T.unpack hostPort, defaultBackendPort)
|
||||||
|
|
||||||
connectToBackend :: String -> Int -> IO Socket
|
connectToBackend :: String -> Int -> IO (Either ConnectError Socket)
|
||||||
connectToBackend host port = do
|
connectToBackend host port = do
|
||||||
addrInfos <- Socket.getAddrInfo
|
resolution <- try $ Socket.getAddrInfo
|
||||||
(Just Socket.defaultHints { Socket.addrSocketType = Socket.Stream })
|
(Just Socket.defaultHints { Socket.addrSocketType = Socket.Stream })
|
||||||
(Just host)
|
(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
|
attemptConnect
|
||||||
[] -> error $ "Cannot resolve: " ++ host ++ ":" ++ show port
|
:: String -> Int -> Socket.AddrInfo -> IO (Either ConnectError Socket)
|
||||||
(addr:_) -> do
|
attemptConnect host port addr =
|
||||||
sock <- Socket.socket
|
bracketOnError
|
||||||
(Socket.addrFamily addr)
|
(Socket.socket
|
||||||
Socket.Stream
|
(Socket.addrFamily addr)
|
||||||
Socket.defaultProtocol
|
Socket.Stream
|
||||||
Socket.connect sock (Socket.addrAddress addr)
|
Socket.defaultProtocol)
|
||||||
return sock
|
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 :: Socket -> Request -> IO ()
|
||||||
sendUpgradeRequest sock req = do
|
sendUpgradeRequest sock req =
|
||||||
let method = requestMethod req
|
let method = requestMethod req
|
||||||
path = rawPathInfo req <> rawQueryString req
|
path = rawPathInfo req <> rawQueryString req
|
||||||
headers = requestHeaders req
|
headers = requestHeaders req
|
||||||
|
requestLine = method <> requestPathSeparator <> path <> httpVersionAndCrlf
|
||||||
requestLine = method <> " " <> path <> " HTTP/1.1\r\n"
|
|
||||||
headerLines = BS.concat
|
headerLines = BS.concat
|
||||||
[ original name <> ": " <> value <> "\r\n"
|
[ original name <> httpFieldSeparator <> value <> httpHeaderLineEnd
|
||||||
| (name, value) <- headers
|
| (name, value) <- headers
|
||||||
]
|
]
|
||||||
fullRequest = requestLine <> headerLines <> "\r\n"
|
fullRequest = requestLine <> headerLines <> httpHeaderLineEnd
|
||||||
|
in SocketBS.sendAll sock fullRequest
|
||||||
SocketBS.sendAll sock fullRequest
|
|
||||||
|
|
||||||
receiveUpgradeResponse :: Socket -> IO ByteString
|
receiveUpgradeResponse :: Socket -> IO ByteString
|
||||||
receiveUpgradeResponse sock = do
|
receiveUpgradeResponse sock = go BS.empty
|
||||||
chunk <- SocketBS.recv sock 4096
|
where
|
||||||
if "\r\n\r\n" `BS.isInfixOf` chunk
|
go !acc
|
||||||
then return chunk
|
| upgradeTerminator `BS.isInfixOf` acc = pure acc
|
||||||
else do
|
| BS.length acc >= maxUpgradeHeaderBytes = pure acc
|
||||||
rest <- receiveUpgradeResponse sock
|
| otherwise = do
|
||||||
return $ chunk <> rest
|
chunk <- SocketBS.recv sock upgradeRecvChunkBytes
|
||||||
|
if BS.null chunk
|
||||||
|
then pure acc
|
||||||
|
else go (acc <> chunk)
|
||||||
|
|
||||||
parseUpgradeStatus :: ByteString -> Maybe Int
|
parseUpgradeStatus :: ByteString -> Maybe Int
|
||||||
parseUpgradeStatus response =
|
parseUpgradeStatus response = case BS8.lines response of
|
||||||
case BS8.words (head $ BS8.lines response) of
|
[] -> Nothing
|
||||||
(_:codeBS:_) ->
|
(firstLine : _) -> case BS8.words firstLine of
|
||||||
case reads (BS8.unpack codeBS) of
|
(_ : codeBS : _) -> case reads (BS8.unpack codeBS) of
|
||||||
[(code, "")] -> Just code
|
[(code, "")] -> Just code
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ import Data.CaseInsensitive (CI)
|
||||||
import qualified Data.CaseInsensitive as CI
|
import qualified Data.CaseInsensitive as CI
|
||||||
import Data.Char (toLower)
|
import Data.Char (toLower)
|
||||||
import Data.Word (Word32)
|
import Data.Word (Word32)
|
||||||
import Network.HTTP.Types (Status, mkStatus)
|
import Network.HTTP.Types (status403)
|
||||||
import Network.HTTP.Types.URI (urlDecode)
|
import Network.HTTP.Types.URI (urlDecode)
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
( Middleware
|
( Middleware
|
||||||
|
|
@ -159,9 +159,6 @@ decisionFromScore hasBlock score threshold matches
|
||||||
wafResponseHeader :: CI ByteString
|
wafResponseHeader :: CI ByteString
|
||||||
wafResponseHeader = "x-aenebris-waf"
|
wafResponseHeader = "x-aenebris-waf"
|
||||||
|
|
||||||
status403 :: Status
|
|
||||||
status403 = mkStatus 403 "Forbidden"
|
|
||||||
|
|
||||||
wafMiddleware :: TVar RuleSet -> Middleware
|
wafMiddleware :: TVar RuleSet -> Middleware
|
||||||
wafMiddleware rsVar app req respond = do
|
wafMiddleware rsVar app req respond = do
|
||||||
rs <- readTVarIO rsVar
|
rs <- readTVarIO rsVar
|
||||||
|
|
@ -173,4 +170,4 @@ wafMiddleware rsVar app req respond = do
|
||||||
[ ("Content-Type", "text/plain; charset=utf-8")
|
[ ("Content-Type", "text/plain; charset=utf-8")
|
||||||
, (wafResponseHeader, "blocked score=" <> BC.pack (show score))
|
, (wafResponseHeader, "blocked score=" <> BC.pack (show score))
|
||||||
]
|
]
|
||||||
"403 Forbidden — request blocked by Aenebris WAF"
|
"403 Forbidden - request blocked by Aenebris WAF"
|
||||||
|
|
|
||||||
|
|
@ -72,13 +72,16 @@ data Target
|
||||||
| TargetUserAgent
|
| TargetUserAgent
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
newtype CompiledRegex = CompiledRegex { unCompiledRegex :: Regex }
|
data CompiledRegex = CompiledRegex
|
||||||
|
{ unCompiledRegex :: !Regex
|
||||||
|
, compiledRegexPattern :: !ByteString
|
||||||
|
}
|
||||||
|
|
||||||
instance Show CompiledRegex where
|
instance Show CompiledRegex where
|
||||||
show _ = "<CompiledRegex>"
|
show r = "<CompiledRegex " ++ show (compiledRegexPattern r) ++ ">"
|
||||||
|
|
||||||
instance Eq CompiledRegex where
|
instance Eq CompiledRegex where
|
||||||
_ == _ = False
|
a == b = compiledRegexPattern a == compiledRegexPattern b
|
||||||
|
|
||||||
data Operator
|
data Operator
|
||||||
= OpRegex !CompiledRegex
|
= OpRegex !CompiledRegex
|
||||||
|
|
@ -115,13 +118,13 @@ compileRegex :: ByteString -> Either String CompiledRegex
|
||||||
compileRegex pat =
|
compileRegex pat =
|
||||||
case compile compOpts execOpts pat of
|
case compile compOpts execOpts pat of
|
||||||
Left err -> Left err
|
Left err -> Left err
|
||||||
Right r -> Right (CompiledRegex r)
|
Right r -> Right (CompiledRegex r pat)
|
||||||
where
|
where
|
||||||
compOpts = TDFA.defaultCompOpt { TDFA.caseSensitive = False }
|
compOpts = TDFA.defaultCompOpt { TDFA.caseSensitive = False }
|
||||||
execOpts = TDFA.defaultExecOpt
|
execOpts = TDFA.defaultExecOpt
|
||||||
|
|
||||||
runRegex :: CompiledRegex -> ByteString -> Bool
|
runRegex :: CompiledRegex -> ByteString -> Bool
|
||||||
runRegex (CompiledRegex r) input =
|
runRegex cr input =
|
||||||
case execute r input of
|
case execute (unCompiledRegex cr) input of
|
||||||
Right (Just _) -> True
|
Right (Just _) -> True
|
||||||
_ -> False
|
_ -> False
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,13 @@ import Aenebris.Backend
|
||||||
( createRuntimeBackend
|
( createRuntimeBackend
|
||||||
, getConnectionCount
|
, getConnectionCount
|
||||||
, isHealthy
|
, isHealthy
|
||||||
|
, rbActiveConnections
|
||||||
|
, rbConsecutiveFailures
|
||||||
, rbServerId
|
, rbServerId
|
||||||
, rbWeight
|
, rbWeight
|
||||||
, recordFailure
|
, recordFailure
|
||||||
, recordSuccess
|
, recordSuccess
|
||||||
|
, trackConnection
|
||||||
, transitionToHealthy
|
, transitionToHealthy
|
||||||
, transitionToRecovering
|
, transitionToRecovering
|
||||||
, transitionToUnhealthy
|
, transitionToUnhealthy
|
||||||
|
|
@ -30,7 +33,8 @@ import Aenebris.Config
|
||||||
, validateConfig
|
, validateConfig
|
||||||
)
|
)
|
||||||
import Aenebris.DDoS.ConnLimit
|
import Aenebris.DDoS.ConnLimit
|
||||||
( defaultConnLimitConfig
|
( currentCount
|
||||||
|
, defaultConnLimitConfig
|
||||||
, defaultPerIPLimit
|
, defaultPerIPLimit
|
||||||
, ipBytesFromSockAddr
|
, ipBytesFromSockAddr
|
||||||
, newConnLimiter
|
, newConnLimiter
|
||||||
|
|
@ -227,6 +231,7 @@ import Aenebris.ML.Loader
|
||||||
( ParseError(..)
|
( ParseError(..)
|
||||||
, parseEnsemble
|
, parseEnsemble
|
||||||
)
|
)
|
||||||
|
import Aenebris.Net.IP (sockAddrToIPBytes)
|
||||||
import Aenebris.Middleware.Redirect (httpsRedirect, httpsRedirectWithPort)
|
import Aenebris.Middleware.Redirect (httpsRedirect, httpsRedirectWithPort)
|
||||||
import Aenebris.Middleware.Security
|
import Aenebris.Middleware.Security
|
||||||
( addSecurityHeaders
|
( addSecurityHeaders
|
||||||
|
|
@ -262,6 +267,7 @@ import Control.Concurrent.STM
|
||||||
( atomically
|
( atomically
|
||||||
, modifyTVar'
|
, modifyTVar'
|
||||||
, newTVarIO
|
, newTVarIO
|
||||||
|
, readTVar
|
||||||
, readTVarIO
|
, readTVarIO
|
||||||
)
|
)
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
|
|
@ -375,6 +381,7 @@ main = hspec $ do
|
||||||
wafSpec
|
wafSpec
|
||||||
honeypotSpec
|
honeypotSpec
|
||||||
geoSpec
|
geoSpec
|
||||||
|
netIpSpec
|
||||||
mlFeaturesSpec
|
mlFeaturesSpec
|
||||||
mlModelSpec
|
mlModelSpec
|
||||||
mlLoaderSpec
|
mlLoaderSpec
|
||||||
|
|
@ -477,31 +484,46 @@ loadBalancerSpec = describe "LoadBalancer" $ do
|
||||||
lb <- createLoadBalancer RoundRobin []
|
lb <- createLoadBalancer RoundRobin []
|
||||||
selectBackend lb `shouldReturn` Nothing
|
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))
|
bks <- mapM (\(i, h) -> createRuntimeBackend i (Server h 1))
|
||||||
[(0, "host-a:80"), (1, "host-b:80"), (2, "host-c:80")]
|
[(0, "host-a:80"), (1, "host-b:80"), (2, "host-c:80")]
|
||||||
lb <- createLoadBalancer RoundRobin bks
|
lb <- createLoadBalancer RoundRobin bks
|
||||||
let getName = fmap (fmap rbServerId) (selectBackend lb)
|
let totalRounds = 9 :: Int
|
||||||
a <- getName
|
selections <- mapM
|
||||||
b <- getName
|
(\_ -> fmap (fmap rbServerId) (selectBackend lb))
|
||||||
c <- getName
|
[1 .. totalRounds]
|
||||||
isJust a `shouldBe` True
|
let counts =
|
||||||
isJust b `shouldBe` True
|
[ length (filter (== Just sid) selections)
|
||||||
isJust c `shouldBe` True
|
| 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))
|
bks <- mapM (\(i, h, w) -> createRuntimeBackend i (Server h w))
|
||||||
[(0, "host-a:80", 1), (1, "host-b:80", 4)]
|
[(0, "host-a:80", 1), (1, "host-b:80", 4)]
|
||||||
lb <- createLoadBalancer WeightedRoundRobin bks
|
lb <- createLoadBalancer WeightedRoundRobin bks
|
||||||
selected <- selectBackend lb
|
let totalRounds = 50 :: Int
|
||||||
isJust selected `shouldBe` True
|
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))
|
bks <- mapM (\(i, h) -> createRuntimeBackend i (Server h 1))
|
||||||
[(0, "host-a:80"), (1, "host-b:80")]
|
[(0, "host-a:80"), (1, "host-b:80")]
|
||||||
lb <- createLoadBalancer LeastConnections bks
|
case bks of
|
||||||
selected <- selectBackend lb
|
[a, _b] -> do
|
||||||
isJust selected `shouldBe` True
|
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 :: Spec
|
||||||
backendSpec = describe "Backend" $ do
|
backendSpec = describe "Backend" $ do
|
||||||
|
|
@ -526,17 +548,21 @@ backendSpec = describe "Backend" $ do
|
||||||
bk <- createRuntimeBackend 0 (Server "host:80" 1)
|
bk <- createRuntimeBackend 0 (Server "host:80" 1)
|
||||||
atomically (getConnectionCount bk) `shouldReturn` 0
|
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)
|
bk <- createRuntimeBackend 0 (Server "host:80" 10)
|
||||||
atomically $ recordFailure bk 3
|
atomically (recordFailure bk 3)
|
||||||
atomically $ recordFailure bk 3
|
atomically (isHealthy bk) `shouldReturn` True
|
||||||
atomically $ recordFailure bk 3
|
atomically (recordFailure bk 3)
|
||||||
rbWeight bk `shouldBe` 10
|
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)
|
bk <- createRuntimeBackend 0 (Server "host:80" 5)
|
||||||
atomically $ recordSuccess bk 5
|
atomically (recordFailure bk 5)
|
||||||
pure ()
|
atomically (readTVar (rbConsecutiveFailures bk)) `shouldReturn` 1
|
||||||
|
atomically (recordSuccess bk 5)
|
||||||
|
atomically (readTVar (rbConsecutiveFailures bk)) `shouldReturn` 0
|
||||||
|
|
||||||
securitySpec :: Spec
|
securitySpec :: Spec
|
||||||
securitySpec = describe "Security headers" $ do
|
securitySpec = describe "Security headers" $ do
|
||||||
|
|
@ -752,11 +778,12 @@ connLimitSpec = describe "ConnLimit" $ do
|
||||||
res <- atomically (tryAcquire cl "9.9.9.9")
|
res <- atomically (tryAcquire cl "9.9.9.9")
|
||||||
res `shouldBe` False
|
res `shouldBe` False
|
||||||
|
|
||||||
it "release decrements counter" $ do
|
it "release decrements counter back to 0" $ do
|
||||||
cl <- newConnLimiter defaultConnLimitConfig
|
cl <- newConnLimiter defaultConnLimitConfig
|
||||||
_ <- atomically (tryAcquire cl "1.2.3.4")
|
_ <- atomically (tryAcquire cl "1.2.3.4")
|
||||||
|
atomically (currentCount cl "1.2.3.4") `shouldReturn` 1
|
||||||
atomically (release cl "1.2.3.4")
|
atomically (release cl "1.2.3.4")
|
||||||
pure ()
|
atomically (currentCount cl "1.2.3.4") `shouldReturn` 0
|
||||||
|
|
||||||
ja4hSpec :: Spec
|
ja4hSpec :: Spec
|
||||||
ja4hSpec = describe "JA4H fingerprint" $ do
|
ja4hSpec = describe "JA4H fingerprint" $ do
|
||||||
|
|
@ -817,6 +844,16 @@ wafSpec = describe "WAF" $ do
|
||||||
severityScore SevWarning `shouldBe` 3
|
severityScore SevWarning `shouldBe` 3
|
||||||
severityScore SevNotice `shouldBe` 2
|
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" $
|
it "default ruleset includes rules" $
|
||||||
length (rsRules defaultRuleSet) `shouldSatisfy` (> 0)
|
length (rsRules defaultRuleSet) `shouldSatisfy` (> 0)
|
||||||
|
|
||||||
|
|
@ -1848,6 +1885,27 @@ mlModelSpec = describe "ML.Model" $ do
|
||||||
validateTree 20 bad `shouldSatisfy`
|
validateTree 20 bad `shouldSatisfy`
|
||||||
(\r -> case r of { Left _ -> True; Right _ -> False })
|
(\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.Text
|
||||||
mlLoaderModel = T.unlines
|
mlLoaderModel = T.unlines
|
||||||
[ "tree"
|
[ "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))
|
(T.replace "leaf_value=0.5\n" "leaf_value=0.5\nbogus=1\n" mlLoaderModel))
|
||||||
`shouldSatisfy` isLeft
|
`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 '='" $
|
describe "feature_names containing '='" $
|
||||||
it "accepts feature_names with '=' in a name" $
|
it "accepts feature_names with '=' in a name" $
|
||||||
parseEnsemble
|
parseEnsemble
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue