2026-07-17 21:43:03 +09:00
|
|
|
module Nuthatch.Config
|
|
|
|
|
( Config (..)
|
|
|
|
|
, loadConfig
|
|
|
|
|
) where
|
|
|
|
|
|
|
|
|
|
import Control.Monad (when)
|
2026-07-18 18:08:41 +09:00
|
|
|
import qualified Data.ByteString as BS
|
|
|
|
|
import Data.List (nub)
|
2026-07-17 21:43:03 +09:00
|
|
|
import Data.Maybe (fromMaybe)
|
|
|
|
|
import Data.Text (Text)
|
|
|
|
|
import qualified Data.Text as T
|
2026-07-18 18:08:41 +09:00
|
|
|
import qualified Data.Text.Encoding as TE
|
2026-07-18 20:48:26 +09:00
|
|
|
import Nuthatch.Streaming (maximumMultipartBytes)
|
2026-07-17 21:43:03 +09:00
|
|
|
import System.Environment (lookupEnv)
|
|
|
|
|
import Text.Read (readMaybe)
|
|
|
|
|
|
|
|
|
|
data Config = Config
|
|
|
|
|
{ cfgListenHost :: String
|
|
|
|
|
, cfgListenPort :: Int
|
2026-07-18 18:08:41 +09:00
|
|
|
, cfgTlsCertPath :: FilePath
|
|
|
|
|
, cfgTlsKeyPath :: FilePath
|
2026-07-17 21:43:03 +09:00
|
|
|
, cfgPublicBaseUrl :: Text
|
|
|
|
|
, cfgJwtSecret :: Text
|
|
|
|
|
, cfgSessionHours :: Integer
|
|
|
|
|
, cfgCookieName :: Text
|
|
|
|
|
, cfgCsrfCookieName :: Text
|
2026-07-18 18:08:41 +09:00
|
|
|
, cfgOidcTransactionCookieName :: Text
|
|
|
|
|
, cfgOidcIssuer :: Text
|
|
|
|
|
, cfgOidcClientId :: Text
|
|
|
|
|
, cfgOidcAdditionalAudiences :: [Text]
|
|
|
|
|
, cfgOidcClientSecret :: Text
|
|
|
|
|
, cfgOidcRedirectUri :: Text
|
|
|
|
|
, cfgOidcTransactionMinutes :: Integer
|
|
|
|
|
, cfgOidcMaxIdTokenSeconds :: Integer
|
2026-07-17 21:43:03 +09:00
|
|
|
, cfgPgConn :: String
|
|
|
|
|
, cfgRedisHost :: String
|
|
|
|
|
, cfgRedisPort :: Int
|
|
|
|
|
, cfgRedisDb :: Integer
|
|
|
|
|
, cfgS3Endpoint :: Text
|
|
|
|
|
, cfgS3Region :: Text
|
|
|
|
|
, cfgS3Bucket :: Text
|
|
|
|
|
, cfgS3AccessKey :: Text
|
|
|
|
|
, cfgS3SecretKey :: Text
|
|
|
|
|
, cfgS3PathStyle :: Bool
|
|
|
|
|
, cfgPersonalQuotaBytes :: Integer
|
|
|
|
|
, cfgSharedQuotaBytes :: Integer
|
|
|
|
|
, cfgMaxObjectBytes :: Integer
|
|
|
|
|
, cfgThrottleBytesPerSec :: Integer
|
|
|
|
|
, cfgThrottleBurstBytes :: Integer
|
|
|
|
|
, cfgListCacheTtlSec :: Integer
|
|
|
|
|
, cfgSearchMaxKeys :: Int
|
|
|
|
|
, cfgRecoveryGraceSec :: Integer
|
2026-07-18 21:25:50 +09:00
|
|
|
, cfgIdempotencyWindowSec :: Integer
|
2026-07-18 22:49:19 +09:00
|
|
|
, cfgObjectRequestTimeoutSec :: Integer
|
2026-07-17 21:43:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadConfig :: IO Config
|
|
|
|
|
loadConfig = do
|
|
|
|
|
listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN"
|
|
|
|
|
let (host, portText) = splitHostPort listen
|
|
|
|
|
port <- requireRead "NUTHATCH_LISTEN port" portText
|
2026-07-18 18:08:41 +09:00
|
|
|
tlsCertPath <- fromMaybe "/tls/tls.crt" <$> lookupEnv "NUTHATCH_TLS_CERT_PATH"
|
|
|
|
|
tlsKeyPath <- fromMaybe "/tls/tls.key" <$> lookupEnv "NUTHATCH_TLS_KEY_PATH"
|
|
|
|
|
publicBase <- T.pack <$> requireEnv "NUTHATCH_PUBLIC_BASE_URL"
|
|
|
|
|
when (not ("https://" `T.isPrefixOf` publicBase)) $
|
|
|
|
|
fail "NUTHATCH_PUBLIC_BASE_URL must use https"
|
2026-07-18 16:16:24 +09:00
|
|
|
jwtSecret <- T.pack <$> requireEnv "NUTHATCH_JWT_SECRET"
|
2026-07-18 18:08:41 +09:00
|
|
|
when (BS.length (TE.encodeUtf8 jwtSecret) < 32) $
|
|
|
|
|
fail "NUTHATCH_JWT_SECRET must be at least 32 bytes"
|
2026-07-17 21:43:03 +09:00
|
|
|
sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12
|
|
|
|
|
cookieName <- T.pack . fromMaybe "nuthatch_session" <$> lookupEnv "NUTHATCH_COOKIE_NAME"
|
|
|
|
|
csrfCookie <- T.pack . fromMaybe "nuthatch_csrf" <$> lookupEnv "NUTHATCH_CSRF_COOKIE_NAME"
|
2026-07-18 18:08:41 +09:00
|
|
|
transactionCookie <- T.pack . fromMaybe "nuthatch_oidc" <$> lookupEnv "NUTHATCH_OIDC_TRANSACTION_COOKIE_NAME"
|
|
|
|
|
oidcIssuer <- T.dropWhileEnd (== '/') . T.pack <$> requireEnv "NUTHATCH_OIDC_ISSUER"
|
|
|
|
|
oidcClientId <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_ID"
|
|
|
|
|
additionalAudienceValue <- lookupEnv "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES"
|
|
|
|
|
let oidcAdditionalAudiences =
|
|
|
|
|
case T.strip . T.pack <$> additionalAudienceValue of
|
|
|
|
|
Nothing -> []
|
|
|
|
|
Just "" -> []
|
|
|
|
|
Just value -> map T.strip (T.splitOn "," value)
|
|
|
|
|
when
|
|
|
|
|
( any T.null oidcAdditionalAudiences
|
|
|
|
|
|| oidcClientId `elem` oidcAdditionalAudiences
|
|
|
|
|
|| length oidcAdditionalAudiences /= length (nub oidcAdditionalAudiences)
|
|
|
|
|
) $
|
|
|
|
|
fail "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES must contain unique non-client values"
|
|
|
|
|
oidcClientSecret <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
|
|
|
|
|
oidcRedirectUri <- T.pack <$> requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
|
|
|
|
|
when (oidcRedirectUri /= publicBase <> "/oidc/callback") $
|
|
|
|
|
fail "NUTHATCH_OIDC_REDIRECT_URI must equal the public callback URL"
|
|
|
|
|
oidcTransactionMinutes <- envInteger "NUTHATCH_OIDC_TRANSACTION_MINUTES" 5
|
|
|
|
|
oidcMaxIdTokenSeconds <- envInteger "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS" 3600
|
2026-07-18 16:16:24 +09:00
|
|
|
pgConn <- requireEnv "NUTHATCH_PG_CONN"
|
|
|
|
|
redisUrl <- requireEnv "NUTHATCH_REDIS_URL"
|
2026-07-17 21:43:03 +09:00
|
|
|
(redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl
|
2026-07-18 16:16:24 +09:00
|
|
|
s3Endpoint <- T.pack <$> requireEnv "NUTHATCH_S3_ENDPOINT"
|
2026-07-17 21:43:03 +09:00
|
|
|
s3Region <- T.pack . fromMaybe "us-east-1" <$> lookupEnv "NUTHATCH_S3_REGION"
|
2026-07-18 16:16:24 +09:00
|
|
|
s3Bucket <- T.pack <$> requireEnv "NUTHATCH_S3_BUCKET"
|
|
|
|
|
s3Access <- T.pack <$> requireEnv "NUTHATCH_S3_ACCESS_KEY"
|
|
|
|
|
s3Secret <- T.pack <$> requireEnv "NUTHATCH_S3_SECRET_KEY"
|
2026-07-17 21:43:03 +09:00
|
|
|
pathStyle <- envBool "NUTHATCH_S3_PATH_STYLE" True
|
|
|
|
|
personalQuota <- envInteger "NUTHATCH_PERSONAL_QUOTA_BYTES" (1024 * 1024 * 1024)
|
|
|
|
|
sharedQuota <- envInteger "NUTHATCH_SHARED_QUOTA_BYTES" (10 * 1024 * 1024 * 1024)
|
|
|
|
|
maxObject <- envInteger "NUTHATCH_MAX_OBJECT_BYTES" (100 * 1024 * 1024)
|
|
|
|
|
throttleRate <- envInteger "NUTHATCH_THROTTLE_BYTES_PER_SEC" (10 * 1024 * 1024)
|
|
|
|
|
throttleBurst <- envInteger "NUTHATCH_THROTTLE_BURST_BYTES" (20 * 1024 * 1024)
|
2026-07-18 20:48:26 +09:00
|
|
|
when (maxObject <= 0 || maxObject > maximumMultipartBytes) $
|
|
|
|
|
fail "NUTHATCH_MAX_OBJECT_BYTES exceeds the supported streaming range"
|
|
|
|
|
when (throttleRate <= 0 || throttleRate > luaExactIntegerMax) $
|
|
|
|
|
fail "NUTHATCH_THROTTLE_BYTES_PER_SEC exceeds the exact throttle range"
|
|
|
|
|
when (throttleBurst <= 0 || throttleBurst > luaExactIntegerMax) $
|
|
|
|
|
fail "NUTHATCH_THROTTLE_BURST_BYTES exceeds the exact throttle range"
|
2026-07-17 21:43:03 +09:00
|
|
|
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
|
|
|
|
|
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
|
|
|
|
|
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
|
2026-07-18 21:25:50 +09:00
|
|
|
idempotencyWindow <- envInteger "NUTHATCH_IDEMPOTENCY_WINDOW_SEC" 86400
|
2026-07-18 22:49:19 +09:00
|
|
|
objectRequestTimeout <- envInteger "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC" 300
|
|
|
|
|
when (recoveryGrace <= 0) $
|
|
|
|
|
fail "NUTHATCH_RECOVERY_GRACE_SEC must be positive"
|
2026-07-18 21:25:50 +09:00
|
|
|
when (idempotencyWindow <= 0) $
|
|
|
|
|
fail "NUTHATCH_IDEMPOTENCY_WINDOW_SEC must be positive"
|
2026-07-18 22:49:19 +09:00
|
|
|
when (objectRequestTimeout <= 0) $
|
|
|
|
|
fail "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC must be positive"
|
2026-07-17 21:43:03 +09:00
|
|
|
pure Config
|
|
|
|
|
{ cfgListenHost = host
|
|
|
|
|
, cfgListenPort = port
|
2026-07-18 18:08:41 +09:00
|
|
|
, cfgTlsCertPath = tlsCertPath
|
|
|
|
|
, cfgTlsKeyPath = tlsKeyPath
|
2026-07-17 21:43:03 +09:00
|
|
|
, cfgPublicBaseUrl = publicBase
|
|
|
|
|
, cfgJwtSecret = jwtSecret
|
|
|
|
|
, cfgSessionHours = sessionHours
|
|
|
|
|
, cfgCookieName = cookieName
|
|
|
|
|
, cfgCsrfCookieName = csrfCookie
|
2026-07-18 18:08:41 +09:00
|
|
|
, cfgOidcTransactionCookieName = transactionCookie
|
|
|
|
|
, cfgOidcIssuer = oidcIssuer
|
|
|
|
|
, cfgOidcClientId = oidcClientId
|
|
|
|
|
, cfgOidcAdditionalAudiences = oidcAdditionalAudiences
|
|
|
|
|
, cfgOidcClientSecret = oidcClientSecret
|
|
|
|
|
, cfgOidcRedirectUri = oidcRedirectUri
|
|
|
|
|
, cfgOidcTransactionMinutes = oidcTransactionMinutes
|
|
|
|
|
, cfgOidcMaxIdTokenSeconds = oidcMaxIdTokenSeconds
|
2026-07-17 21:43:03 +09:00
|
|
|
, cfgPgConn = pgConn
|
|
|
|
|
, cfgRedisHost = redisHost
|
|
|
|
|
, cfgRedisPort = redisPort
|
|
|
|
|
, cfgRedisDb = redisDb
|
|
|
|
|
, cfgS3Endpoint = s3Endpoint
|
|
|
|
|
, cfgS3Region = s3Region
|
|
|
|
|
, cfgS3Bucket = s3Bucket
|
|
|
|
|
, cfgS3AccessKey = s3Access
|
|
|
|
|
, cfgS3SecretKey = s3Secret
|
|
|
|
|
, cfgS3PathStyle = pathStyle
|
|
|
|
|
, cfgPersonalQuotaBytes = personalQuota
|
|
|
|
|
, cfgSharedQuotaBytes = sharedQuota
|
|
|
|
|
, cfgMaxObjectBytes = maxObject
|
|
|
|
|
, cfgThrottleBytesPerSec = throttleRate
|
|
|
|
|
, cfgThrottleBurstBytes = throttleBurst
|
|
|
|
|
, cfgListCacheTtlSec = listTtl
|
|
|
|
|
, cfgSearchMaxKeys = searchMax
|
|
|
|
|
, cfgRecoveryGraceSec = recoveryGrace
|
2026-07-18 21:25:50 +09:00
|
|
|
, cfgIdempotencyWindowSec = idempotencyWindow
|
2026-07-18 22:49:19 +09:00
|
|
|
, cfgObjectRequestTimeoutSec = objectRequestTimeout
|
2026-07-17 21:43:03 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:48:26 +09:00
|
|
|
luaExactIntegerMax :: Integer
|
|
|
|
|
luaExactIntegerMax = 9007199254740991
|
|
|
|
|
|
2026-07-17 21:43:03 +09:00
|
|
|
splitHostPort :: String -> (String, String)
|
|
|
|
|
splitHostPort s =
|
|
|
|
|
case break (== ':') s of
|
|
|
|
|
(h, ':' : p) -> (h, p)
|
|
|
|
|
_ -> ("127.0.0.1", "8080")
|
|
|
|
|
|
2026-07-18 16:16:24 +09:00
|
|
|
requireEnv :: String -> IO String
|
|
|
|
|
requireEnv name = do
|
|
|
|
|
value <- lookupEnv name
|
|
|
|
|
case value of
|
|
|
|
|
Just present | not (null present) -> pure present
|
|
|
|
|
_ -> fail $ name <> " is required"
|
|
|
|
|
|
2026-07-17 21:43:03 +09:00
|
|
|
envInteger :: String -> Integer -> IO Integer
|
|
|
|
|
envInteger name def = do
|
|
|
|
|
m <- lookupEnv name
|
|
|
|
|
case m of
|
|
|
|
|
Nothing -> pure def
|
|
|
|
|
Just v -> requireRead name v
|
|
|
|
|
|
|
|
|
|
envBool :: String -> Bool -> IO Bool
|
|
|
|
|
envBool name def = do
|
|
|
|
|
m <- lookupEnv name
|
|
|
|
|
case fmap T.toLower (T.pack <$> m) of
|
|
|
|
|
Nothing -> pure def
|
|
|
|
|
Just "1" -> pure True
|
|
|
|
|
Just "true" -> pure True
|
|
|
|
|
Just "yes" -> pure True
|
|
|
|
|
Just "0" -> pure False
|
|
|
|
|
Just "false" -> pure False
|
|
|
|
|
Just "no" -> pure False
|
|
|
|
|
Just other -> fail $ name <> ": invalid boolean " <> T.unpack other
|
|
|
|
|
|
|
|
|
|
requireRead :: (Read a) => String -> String -> IO a
|
|
|
|
|
requireRead name v =
|
|
|
|
|
case readMaybe v of
|
|
|
|
|
Just x -> pure x
|
|
|
|
|
Nothing -> fail $ name <> ": cannot parse " <> v
|
|
|
|
|
|
|
|
|
|
parseRedisUrl :: String -> IO (String, Int, Integer)
|
|
|
|
|
parseRedisUrl url =
|
|
|
|
|
case T.stripPrefix "redis://" (T.pack url) of
|
|
|
|
|
Nothing -> fail "NUTHATCH_REDIS_URL must start with redis://"
|
|
|
|
|
Just rest -> do
|
|
|
|
|
let (hostPort, dbPart) =
|
|
|
|
|
case T.breakOn "/" rest of
|
|
|
|
|
(hp, d) | T.null d -> (hp, "/0")
|
|
|
|
|
(hp, d) -> (hp, d)
|
|
|
|
|
(hostText, portText) =
|
|
|
|
|
case T.breakOn ":" hostPort of
|
|
|
|
|
(h, p) | T.null p -> (h, "6379")
|
|
|
|
|
(h, p) -> (h, T.drop 1 p)
|
|
|
|
|
port <- requireRead "redis port" (T.unpack portText)
|
|
|
|
|
db <- requireRead "redis db" (T.unpack (T.drop 1 dbPart))
|
|
|
|
|
pure (T.unpack hostText, port, db)
|