packages feed

pg-wire-0.1.0.0: src/PgWire/Connection/Config.hs

-- | Configuration types for PostgreSQL connections.
--
-- Supports both URI format (@postgres:\/\/user:pass\@host:port\/db@) and
-- key=value format (@host=localhost port=5432 dbname=mydb@). Multi-host
-- failover, TLS modes, and session attribute targeting are configurable.
module PgWire.Connection.Config
  ( ConnConfig (..)
  , TlsMode (..)
  , TargetSessionAttrs (..)
  , defaultConnConfig
  , parseConnString
  , parseHosts
  ) where

import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.Char (isDigit)
import Data.Maybe (fromMaybe)
import Data.Time (NominalDiffTime)
import Data.Word (Word16)
import Network.Socket (HostName, PortNumber)

-- | TLS connection mode, matching PostgreSQL's @sslmode@ parameter.
data TlsMode
  = TlsDisable       -- ^ No SSL
  | TlsPrefer        -- ^ Try SSL, fall back to plain
  | TlsRequire       -- ^ Require SSL, don't verify server cert
  | TlsVerifyCa      -- ^ Require SSL, verify server cert against CA
  | TlsVerifyFull    -- ^ Require SSL, verify cert + hostname match
  deriving stock (Show, Eq)

-- | Session attribute requirements for multi-host failover.
data TargetSessionAttrs
  = SessionAny            -- ^ Connect to any server
  | SessionReadWrite      -- ^ Must be a read-write server (primary)
  | SessionReadOnly       -- ^ Must be a read-only server (standby)
  | SessionPrimary        -- ^ Must be the primary
  | SessionStandby        -- ^ Must be a standby
  | SessionPreferStandby  -- ^ Prefer standby, fall back to primary
  deriving stock (Show, Eq)

-- | Connection configuration for a single PostgreSQL server.
data ConnConfig = ConnConfig
  { ccHost :: HostName
  -- ^ Primary host. For multi-host, use comma-separated: @\"host1,host2\"@.
  , ccPort :: PortNumber
  , ccDatabase :: ByteString
  , ccUser :: ByteString
  , ccPassword :: ByteString
  , ccTls :: TlsMode
  , ccAppName :: ByteString
  , ccConnectTimeout :: NominalDiffTime
  -- ^ Timeout for establishing the TCP connection (seconds). 0 = no timeout.
  , ccQueryTimeout :: NominalDiffTime
  -- ^ Default timeout for query execution (seconds). 0 = no timeout.
  -- Can be overridden per-query with 'withQueryTimeout'.
  , ccSslCert :: Maybe FilePath
  -- ^ Path to client SSL certificate file (@sslcert@).
  , ccSslKey :: Maybe FilePath
  -- ^ Path to client SSL private key file (@sslkey@).
  , ccSslRootCert :: Maybe FilePath
  -- ^ Path to SSL certificate authority (CA) file (@sslrootcert@).
  -- If @\"system\"@, uses the system certificate store.
  , ccKeepalives :: Bool
  -- ^ Enable TCP keepalives (default: True).
  , ccKeepalivesIdle :: Int
  -- ^ Seconds before first keepalive probe (default: 0 = system default).
  , ccKeepalivesInterval :: Int
  -- ^ Seconds between keepalive probes (default: 0 = system default).
  , ccKeepalivesCount :: Int
  -- ^ Number of failed probes before disconnect (default: 0 = system default).
  , ccTargetSessionAttrs :: TargetSessionAttrs
  -- ^ Required session attributes for multi-host failover (default: 'SessionAny').
  , ccClientEncoding :: ByteString
  -- ^ Client encoding (default: @\"UTF8\"@). Set to @\"auto\"@ for auto-detection.
  , ccOptions :: ByteString
  -- ^ Extra command-line options to send to the server at startup.
  , ccLoadBalanceHosts :: Bool
  -- ^ Randomize host order for multi-host connections (default: False).
  , ccPreparedStatements :: Bool
  -- ^ Use named prepared statements (default: True). Set to False for
  -- compatibility with PgBouncer in transaction mode or pgpool.
  -- When False, all queries use the unnamed statement which is
  -- parsed and discarded each time.
  , ccMaxPreparedStatements :: !Int
  -- ^ Maximum number of prepared statements cached per connection
  -- (default: 256). When the cache is full, the least recently used
  -- statement is evicted. Higher values reduce re-parses for workloads
  -- with many distinct queries, at the cost of server-side memory
  -- (each cached statement holds a parse tree in PostgreSQL).
  -- Ignored when 'ccPreparedStatements' is False.
  }
  deriving stock (Show)

-- | Default connection configuration: @localhost:5432@, no TLS, 10s connect timeout.
defaultConnConfig :: ConnConfig
defaultConnConfig =
  ConnConfig
    { ccHost = "localhost"
    , ccPort = 5432
    , ccDatabase = ""
    , ccUser = ""
    , ccPassword = ""
    , ccTls = TlsDisable
    , ccAppName = "pg-wire"
    , ccConnectTimeout = 10
    , ccQueryTimeout = 0
    , ccSslCert = Nothing
    , ccSslKey = Nothing
    , ccSslRootCert = Nothing
    , ccKeepalives = True
    , ccKeepalivesIdle = 0
    , ccKeepalivesInterval = 0
    , ccKeepalivesCount = 0
    , ccTargetSessionAttrs = SessionAny
    , ccClientEncoding = "UTF8"
    , ccOptions = ""
    , ccLoadBalanceHosts = False
    , ccPreparedStatements = True
    , ccMaxPreparedStatements = 256
    }

-- | Parse a PostgreSQL connection string.
-- Supports both URI format (@postgres://user:pass\@host:port/db@)
-- and key=value format (@host=localhost port=5432 dbname=mydb@).
parseConnString :: ByteString -> Either String ConnConfig
parseConnString bs
  | "postgres://" `BS8.isPrefixOf` bs || "postgresql://" `BS8.isPrefixOf` bs =
      parseUri bs
  | otherwise =
      parseKeyValue bs

parseUri :: ByteString -> Either String ConnConfig
parseUri bs = do
  -- Strip scheme: "postgres://..." -> drop up to first '/', then drop "//"
  let afterScheme = BS8.drop 2 (BS8.dropWhile (/= '/') bs) -- drop "scheme://"
      afterSlash = afterScheme

  -- Split user:pass@host:port/db?params
  let (authHost, pathQuery) = case BS8.break (== '/') afterSlash of
        (ah, pq) -> (ah, BS8.drop 1 pq)
      dbAndParams = pathQuery
      (db, queryStr) = BS8.break (== '?') dbAndParams

  -- Split auth@host
  let (auth, hostPort) = case BS8.breakEnd (== '@') authHost of
        (a, _) | BS8.null a -> ("", authHost)
        (a, hp) -> (BS8.init a, hp) -- drop trailing '@'

  -- Split user:pass
  let (user, pass) = case BS8.break (== ':') auth of
        (u, p)
          | BS8.null p -> (u, "")
          | otherwise -> (u, BS8.drop 1 p)

  -- Split host:port
  let (host, portStr) = case BS8.break (== ':') hostPort of
        (h, p)
          | BS8.null p -> (h, "5432")
          | otherwise -> (h, BS8.drop 1 p)
      port = maybe 5432 fromIntegral (readPort portStr)

  -- Parse query params for sslmode
  let params = parseQueryParams (BS8.drop 1 queryStr) -- drop '?'
      tlsMode = case lookup "sslmode" params of
        Just "require"     -> TlsRequire
        Just "prefer"      -> TlsPrefer
        Just "disable"     -> TlsDisable
        Just "verify-ca"   -> TlsVerifyCa
        Just "verify-full" -> TlsVerifyFull
        _ -> TlsDisable
      sslCert = BS8.unpack <$> lookup "sslcert" params
      sslKey = BS8.unpack <$> lookup "sslkey" params
      sslRootCert = BS8.unpack <$> lookup "sslrootcert" params

  Right
    ConnConfig
      { ccHost = BS8.unpack host
      , ccPort = port
      , ccDatabase = db
      , ccUser = user
      , ccPassword = pass
      , ccTls = tlsMode
      , ccAppName = "pg-wire"
      , ccConnectTimeout = readTimeout (lookup "connect_timeout" params)
      , ccQueryTimeout = 0
      , ccSslCert = sslCert
      , ccSslKey = sslKey
      , ccSslRootCert = sslRootCert
      , ccKeepalives = True
      , ccKeepalivesIdle = 0
      , ccKeepalivesInterval = 0
      , ccKeepalivesCount = 0
      , ccTargetSessionAttrs = maybe SessionAny parseSessionAttrs
          (lookup "target_session_attrs" params)
      , ccClientEncoding = fromMaybe "UTF8" (lookup "client_encoding" params)
      , ccOptions = fromMaybe "" (lookup "options" params)
      , ccLoadBalanceHosts = lookup "load_balance_hosts" params == Just "random"
      , ccPreparedStatements = lookup "prepared_statements" params /= Just "false"
      , ccMaxPreparedStatements = maybe 256 (\v -> readIntDef 256 v) (lookup "statement_cache_size" params)
      }

readTimeout :: Maybe ByteString -> NominalDiffTime
readTimeout Nothing = 10
readTimeout (Just bs) = case BS8.readInt bs of
  Just (n, _) | n > 0 -> fromIntegral n
  _ -> 10

parseKeyValue :: ByteString -> Either String ConnConfig
parseKeyValue bs =
  let pairs = map parsePair (BS8.words bs)
      get key def = fromMaybe def (lookup key pairs)
      portStr = get "port" "5432"
      port = maybe 5432 fromIntegral (readPort portStr)
      tlsMode = case get "sslmode" "disable" of
        "require"     -> TlsRequire
        "prefer"      -> TlsPrefer
        "verify-ca"   -> TlsVerifyCa
        "verify-full" -> TlsVerifyFull
        _ -> TlsDisable
      getMaybe key = let v = get key "" in if BS8.null v then Nothing else Just (BS8.unpack v)
   in Right
        ConnConfig
          { ccHost = BS8.unpack (get "host" "localhost")
          , ccPort = port
          , ccDatabase = get "dbname" (get "database" "")
          , ccUser = get "user" ""
          , ccPassword = get "password" ""
          , ccTls = tlsMode
          , ccAppName = get "application_name" "pg-wire"
          , ccConnectTimeout = case BS8.readInt (get "connect_timeout" "10") of
              Just (n, _) | n > 0 -> fromIntegral n
              _ -> 10
          , ccQueryTimeout = case BS8.readInt (get "query_timeout" "0") of
              Just (n, _) | n > 0 -> fromIntegral n
              _ -> 0
          , ccSslCert = getMaybe "sslcert"
          , ccSslKey = getMaybe "sslkey"
          , ccSslRootCert = getMaybe "sslrootcert"
          , ccKeepalives = get "keepalives" "1" /= "0"
          , ccKeepalivesIdle = readIntDef 0 (get "keepalives_idle" "0")
          , ccKeepalivesInterval = readIntDef 0 (get "keepalives_interval" "0")
          , ccKeepalivesCount = readIntDef 0 (get "keepalives_count" "0")
          , ccTargetSessionAttrs = parseSessionAttrs (get "target_session_attrs" "any")
          , ccClientEncoding = get "client_encoding" "UTF8"
          , ccOptions = get "options" ""
          , ccLoadBalanceHosts = get "load_balance_hosts" "disable" == "random"
          , ccPreparedStatements = get "prepared_statements" "true" /= "false"
          , ccMaxPreparedStatements = readIntDef 256 (get "statement_cache_size" "256")
          }
  where
    parsePair p =
      let (k, v) = BS8.break (== '=') p
       in (k, BS8.drop 1 v)

readIntDef :: Int -> ByteString -> Int
readIntDef d bs = case BS8.readInt bs of
  Just (n, _) -> n
  Nothing -> d

readPort :: ByteString -> Maybe Word16
readPort bs
  | BS8.null bs = Nothing
  | BS8.all (\c -> isDigit c) bs =
      let n = BS8.foldl' (\acc c -> acc * 10 + fromIntegral (fromEnum c - 48)) 0 bs :: Int
       in if n > 0 && n <= 65535 then Just (fromIntegral n) else Nothing
  | otherwise = Nothing

parseQueryParams :: ByteString -> [(ByteString, ByteString)]
parseQueryParams bs
  | BS8.null bs = []
  | otherwise =
      [ (k, BS8.drop 1 v)
      | param <- BS8.split '&' bs
      , let (k, v) = BS8.break (== '=') param
      , not (BS8.null k)
      ]

parseSessionAttrs :: ByteString -> TargetSessionAttrs
parseSessionAttrs "any"              = SessionAny
parseSessionAttrs "read-write"       = SessionReadWrite
parseSessionAttrs "read-only"        = SessionReadOnly
parseSessionAttrs "primary"          = SessionPrimary
parseSessionAttrs "standby"          = SessionStandby
parseSessionAttrs "prefer-standby"   = SessionPreferStandby
parseSessionAttrs _                  = SessionAny

-- | Parse a comma-separated host string into individual hosts.
-- @\"host1,host2,host3\"@ → @[\"host1\", \"host2\", \"host3\"]@
parseHosts :: String -> [String]
parseHosts [] = ["localhost"]
parseHosts s = go s
  where
    go [] = []
    go str = let (h, rest) = break (== ',') str
              in h : case rest of
                       [] -> []
                       (_ : rs) -> go rs