Complete runtime resilience and audit controls

This commit is contained in:
ms2k 2026-07-19 03:41:01 +09:00
commit 728abdbbf1

View file

@ -1,18 +1,32 @@
module Nuthatch.Config
( Config (..)
, Secret
, secret
, revealSecret
, loadConfig
, parseRedisUri
, validateAbsoluteUri
) where
import Control.Exception (SomeException, try)
import Control.Monad (when)
import qualified Data.ByteString as BS
import Data.List (nub)
import Data.Maybe (fromMaybe)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Env
import Nuthatch.Streaming (maximumMultipartBytes)
import System.Environment (lookupEnv)
import Text.Read (readMaybe)
import qualified Text.URI as URI
newtype Secret value = Secret value
secret :: value -> Secret value
secret = Secret
revealSecret :: Secret value -> value
revealSecret (Secret value) = value
data Config = Config
{ cfgListenHost :: String
@ -20,7 +34,7 @@ data Config = Config
, cfgTlsCertPath :: FilePath
, cfgTlsKeyPath :: FilePath
, cfgPublicBaseUrl :: Text
, cfgJwtSecret :: Text
, cfgJwtSecret :: Secret Text
, cfgSessionHours :: Integer
, cfgCookieName :: Text
, cfgCsrfCookieName :: Text
@ -28,19 +42,19 @@ data Config = Config
, cfgOidcIssuer :: Text
, cfgOidcClientId :: Text
, cfgOidcAdditionalAudiences :: [Text]
, cfgOidcClientSecret :: Text
, cfgOidcClientSecret :: Secret Text
, cfgOidcRedirectUri :: Text
, cfgOidcTransactionMinutes :: Integer
, cfgOidcMaxIdTokenSeconds :: Integer
, cfgPgConn :: String
, cfgPgConn :: Secret String
, cfgRedisHost :: String
, cfgRedisPort :: Int
, cfgRedisDb :: Integer
, cfgS3Endpoint :: Text
, cfgS3Region :: Text
, cfgS3Bucket :: Text
, cfgS3AccessKey :: Text
, cfgS3SecretKey :: Text
, cfgS3AccessKey :: Secret Text
, cfgS3SecretKey :: Secret Text
, cfgS3PathStyle :: Bool
, cfgPersonalQuotaBytes :: Integer
, cfgSharedQuotaBytes :: Integer
@ -55,53 +69,62 @@ data Config = Config
, cfgRecoveryGraceSec :: Integer
, cfgIdempotencyWindowSec :: Integer
, cfgObjectRequestTimeoutSec :: Integer
, cfgStartupAttempts :: Int
, cfgStartupDelayMs :: Int
, cfgAuditObjectKeys :: Bool
}
loadConfig :: IO Config
loadConfig = do
listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN"
listen <- envString "NUTHATCH_LISTEN" "127.0.0.1:8080"
let (host, portText) = splitHostPort listen
port <- requireRead "NUTHATCH_LISTEN port" portText
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"
jwtSecret <- T.pack <$> requireEnv "NUTHATCH_JWT_SECRET"
when (BS.length (TE.encodeUtf8 jwtSecret) < 32) $
port <- parseValue "NUTHATCH_LISTEN port" portText
when (T.null (T.pack host) || port <= 0 || port > 65535) $
fail "NUTHATCH_LISTEN must contain a host and port between 1 and 65535"
tlsCertPath <- envString "NUTHATCH_TLS_CERT_PATH" "/tls/tls.crt"
tlsKeyPath <- envString "NUTHATCH_TLS_KEY_PATH" "/tls/tls.key"
publicBase <- requireText "NUTHATCH_PUBLIC_BASE_URL"
>>= validateAbsoluteUri "NUTHATCH_PUBLIC_BASE_URL" ["https"]
jwtSecretText <- requireTextSecret "NUTHATCH_JWT_SECRET"
when (BS.length (TE.encodeUtf8 jwtSecretText) < 32) $
fail "NUTHATCH_JWT_SECRET must be at least 32 bytes"
let jwtSecret = secret jwtSecretText
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"
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)
cookieName <- T.pack <$> envString "NUTHATCH_COOKIE_NAME" "nuthatch_session"
csrfCookie <- T.pack <$> envString "NUTHATCH_CSRF_COOKIE_NAME" "nuthatch_csrf"
transactionCookie <- T.pack <$>
envString "NUTHATCH_OIDC_TRANSACTION_COOKIE_NAME" "nuthatch_oidc"
oidcIssuerValue <- requireText "NUTHATCH_OIDC_ISSUER"
oidcIssuer <- T.dropWhileEnd (== '/') <$>
validateAbsoluteUri "NUTHATCH_OIDC_ISSUER" ["http", "https"] oidcIssuerValue
oidcClientId <- requireText "NUTHATCH_OIDC_CLIENT_ID"
additionalAudienceValue <- T.strip . T.pack <$>
envString "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES" ""
let oidcAdditionalAudiences
| T.null additionalAudienceValue = []
| otherwise = map T.strip (T.splitOn "," additionalAudienceValue)
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"
oidcClientSecret <- secret <$> requireTextSecret "NUTHATCH_OIDC_CLIENT_SECRET"
oidcRedirectUri <- requireText "NUTHATCH_OIDC_REDIRECT_URI"
>>= validateAbsoluteUri "NUTHATCH_OIDC_REDIRECT_URI" ["https"]
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
pgConn <- requireEnv "NUTHATCH_PG_CONN"
redisUrl <- requireEnv "NUTHATCH_REDIS_URL"
(redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl
s3Endpoint <- T.pack <$> requireEnv "NUTHATCH_S3_ENDPOINT"
s3Region <- T.pack . fromMaybe "us-east-1" <$> lookupEnv "NUTHATCH_S3_REGION"
s3Bucket <- T.pack <$> requireEnv "NUTHATCH_S3_BUCKET"
s3Access <- T.pack <$> requireEnv "NUTHATCH_S3_ACCESS_KEY"
s3Secret <- T.pack <$> requireEnv "NUTHATCH_S3_SECRET_KEY"
pgConn <- secret <$> requireStringSecret "NUTHATCH_PG_CONN"
redisUrl <- requireText "NUTHATCH_REDIS_URL"
(redisHost, redisPort, redisDb) <- parseRedisUri redisUrl
s3Endpoint <- requireText "NUTHATCH_S3_ENDPOINT"
>>= validateOriginUri "NUTHATCH_S3_ENDPOINT" ["http", "https"]
s3Region <- T.pack <$> envString "NUTHATCH_S3_REGION" "us-east-1"
s3Bucket <- requireText "NUTHATCH_S3_BUCKET"
s3Access <- secret <$> requireTextSecret "NUTHATCH_S3_ACCESS_KEY"
s3Secret <- secret <$> requireTextSecret "NUTHATCH_S3_SECRET_KEY"
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)
@ -115,27 +138,34 @@ loadConfig = do
when (throttleBurst <= 0 || throttleBurst > luaExactIntegerMax) $
fail "NUTHATCH_THROTTLE_BURST_BYTES exceeds the exact throttle range"
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
browsePageSize <- fromInteger <$> envInteger "NUTHATCH_BROWSE_PAGE_SIZE" 200
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
browsePageSize <- envBoundedInt "NUTHATCH_BROWSE_PAGE_SIZE" 200 1 1000
searchMax <- envBoundedInt "NUTHATCH_SEARCH_MAX_KEYS" 500 1 100000
searchTimeoutMs <- envInteger "NUTHATCH_SEARCH_TIMEOUT_MS" 2000
deletePageSize <- fromInteger <$> envInteger "NUTHATCH_DELETE_PAGE_SIZE" 100
deletePageSize <- envBoundedInt "NUTHATCH_DELETE_PAGE_SIZE" 100 1 1000
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
idempotencyWindow <- envInteger "NUTHATCH_IDEMPOTENCY_WINDOW_SEC" 86400
objectRequestTimeout <- envInteger "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC" 300
startupAttempts <- envBoundedInt "NUTHATCH_STARTUP_ATTEMPTS" 60 1 1000
startupDelayMs <- envBoundedInt "NUTHATCH_STARTUP_DELAY_MS" 1000 1 60000
auditObjectKeys <- envBool "NUTHATCH_AUDIT_OBJECT_KEYS" False
when (recoveryGrace <= 0) $
fail "NUTHATCH_RECOVERY_GRACE_SEC must be positive"
when (idempotencyWindow <= 0) $
fail "NUTHATCH_IDEMPOTENCY_WINDOW_SEC must be positive"
when (objectRequestTimeout <= 0) $
fail "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC must be positive"
when (browsePageSize <= 0 || browsePageSize > 1000) $
fail "NUTHATCH_BROWSE_PAGE_SIZE must be between 1 and 1000"
when (searchMax <= 0 || searchMax > 100000) $
fail "NUTHATCH_SEARCH_MAX_KEYS must be between 1 and 100000"
when (sessionHours <= 0 || sessionHours > 168) $
fail "NUTHATCH_SESSION_HOURS must be between 1 and 168"
when (oidcTransactionMinutes <= 0 || oidcTransactionMinutes > 60) $
fail "NUTHATCH_OIDC_TRANSACTION_MINUTES must be between 1 and 60"
when (oidcMaxIdTokenSeconds <= 0 || oidcMaxIdTokenSeconds > 86400) $
fail "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS must be between 1 and 86400"
when (personalQuota <= 0 || sharedQuota <= 0) $
fail "configured quotas must be positive"
when (listTtl <= 0 || listTtl > 86400) $
fail "NUTHATCH_LIST_CACHE_TTL_SEC must be between 1 and 86400"
when (searchTimeoutMs <= 0 || searchTimeoutMs > 60000) $
fail "NUTHATCH_SEARCH_TIMEOUT_MS must be between 1 and 60000"
when (deletePageSize <= 0 || deletePageSize > 1000) $
fail "NUTHATCH_DELETE_PAGE_SIZE must be between 1 and 1000"
pure Config
{ cfgListenHost = host
, cfgListenPort = port
@ -177,6 +207,9 @@ loadConfig = do
, cfgRecoveryGraceSec = recoveryGrace
, cfgIdempotencyWindowSec = idempotencyWindow
, cfgObjectRequestTimeoutSec = objectRequestTimeout
, cfgStartupAttempts = startupAttempts
, cfgStartupDelayMs = startupDelayMs
, cfgAuditObjectKeys = auditObjectKeys
}
luaExactIntegerMax :: Integer
@ -184,56 +217,117 @@ luaExactIntegerMax = 9007199254740991
splitHostPort :: String -> (String, String)
splitHostPort s =
case break (== ':') s of
(h, ':' : p) -> (h, p)
_ -> ("127.0.0.1", "8080")
requireEnv :: String -> IO String
requireEnv name = do
value <- lookupEnv name
case value of
Just present | not (null present) -> pure present
_ -> fail $ name <> " is required"
case span (/= ':') s of
(h, ':' : p) | not (null h) && not (null p) -> (h, p)
_ -> ("", "")
envInteger :: String -> Integer -> IO Integer
envInteger name def = do
m <- lookupEnv name
case m of
Nothing -> pure def
Just v -> requireRead name v
envInteger name defaultValue =
Env.parse configInfo (Env.var Env.auto name (Env.def defaultValue))
envBoundedInt :: String -> Integer -> Integer -> Integer -> IO Int
envBoundedInt name defaultValue minimumValue maximumValue = do
value <- envInteger name defaultValue
when (value < minimumValue || value > maximumValue) $
fail
(name <> " must be between "
<> show minimumValue <> " and " <> show maximumValue)
pure (fromInteger value)
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
envBool name defaultValue =
Env.parse configInfo $
Env.var (Env.eitherReader parseBoolean) name (Env.def defaultValue)
where
parseBoolean value =
case map toLowerAscii value of
"1" -> Right True
"true" -> Right True
"yes" -> Right True
"0" -> Right False
"false" -> Right False
"no" -> Right False
_ -> Left "expected true or false"
toLowerAscii c
| c >= 'A' && c <= 'Z' = toEnum (fromEnum c + 32)
| otherwise = c
requireRead :: (Read a) => String -> String -> IO a
requireRead name v =
case readMaybe v of
Just x -> pure x
Nothing -> fail $ name <> ": cannot parse " <> v
envString :: String -> String -> IO String
envString name defaultValue =
Env.parse configInfo (Env.var Env.str name (Env.def defaultValue))
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)
requireText :: String -> IO Text
requireText name = T.pack <$>
Env.parse configInfo (Env.var Env.nonempty name mempty)
requireTextSecret :: String -> IO Text
requireTextSecret name = T.pack <$>
Env.parse configInfo (Env.sensitive (Env.var Env.nonempty name mempty))
requireStringSecret :: String -> IO String
requireStringSecret name =
Env.parse configInfo (Env.sensitive (Env.var Env.nonempty name mempty))
parseValue :: Read value => String -> String -> IO value
parseValue name value =
case reads value of
[(parsed, "")] -> pure parsed
_ -> fail (name <> " is invalid")
configInfo :: Env.Info Env.Error -> Env.Info Env.Error
configInfo = Env.header "Nuthatch configuration"
validateAbsoluteUri :: String -> [Text] -> Text -> IO Text
validateAbsoluteUri name allowedSchemes value = do
parsed <- try (URI.mkURI value) :: IO (Either SomeException URI.URI)
uri <- either (const (fail (name <> " is not a valid URI"))) pure parsed
scheme <- maybe (fail (name <> " must be absolute"))
(pure . T.toLower . URI.unRText)
(URI.uriScheme uri)
when (scheme `notElem` allowedSchemes) $
fail (name <> " uses an unsupported scheme")
authority <- either
(const (fail (name <> " must contain an authority")))
pure
(URI.uriAuthority uri)
when (URI.authUserInfo authority /= Nothing) $
fail (name <> " must not contain credentials")
when (T.null (URI.unRText (URI.authHost authority))) $
fail (name <> " must contain a host")
pure (URI.render uri)
validateOriginUri :: String -> [Text] -> Text -> IO Text
validateOriginUri name allowedSchemes value = do
rendered <- validateAbsoluteUri name allowedSchemes value
parsed <- URI.mkURI rendered
when
(URI.uriPath parsed /= Nothing
|| not (null (URI.uriQuery parsed))
|| URI.uriFragment parsed /= Nothing) $
fail (name <> " must be an origin without path, query, or fragment")
pure rendered
parseRedisUri :: Text -> IO (String, Int, Integer)
parseRedisUri value = do
rendered <- validateAbsoluteUri "NUTHATCH_REDIS_URL" ["redis"] value
parsed <- URI.mkURI rendered
authority <- either
(const (fail "NUTHATCH_REDIS_URL must contain an authority"))
pure
(URI.uriAuthority parsed)
when (not (null (URI.uriQuery parsed)) || URI.uriFragment parsed /= Nothing) $
fail "NUTHATCH_REDIS_URL must not contain a query or fragment"
database <- case URI.uriPath parsed of
Nothing -> pure 0
Just (_, segment :| []) ->
parseValue "NUTHATCH_REDIS_URL database" (T.unpack (URI.unRText segment))
_ -> fail "NUTHATCH_REDIS_URL must contain at most one database path segment"
let port = maybe 6379 fromIntegral (URI.authPort authority)
when (database < 0 || database > 15) $
fail "NUTHATCH_REDIS_URL database must be between 0 and 15"
let renderedHost = URI.unRText (URI.authHost authority)
connectionHost =
fromMaybeText renderedHost (T.stripPrefix "[" renderedHost >>= T.stripSuffix "]")
pure (T.unpack connectionHost, port, database)
where
fromMaybeText fallback = maybe fallback id