nova-cache-0.3.2.1: exe/Main.hs
module Main (main) where
import Data.Bifunctor (first)
import Data.ByteArray (constEq)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Network.HTTP.Types as HTTP
import Network.Wai
( Application,
Request,
RequestBodyLength (..),
Response,
ResponseReceived,
pathInfo,
requestBodyLength,
requestHeaders,
requestMethod,
responseLBS,
strictRequestBody,
)
import qualified Network.Wai.Handler.Warp as Warp
import Network.Wai.Middleware.RequestLogger (logStdout)
import NovaCache.NarInfo (NarInfo (..), parseNarInfo, renderNarInfo)
import NovaCache.Signing (SecretKey, parseSecretKey, sign)
import NovaCache.Store (FileStore, getCacheInfo, listNarInfoHashes, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo)
import NovaCache.Validate (validateNarInfo)
import System.Environment (getArgs, lookupEnv)
import System.IO (hPutStrLn, stderr)
import Text.Read (readMaybe)
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- | Default server port.
defaultPort :: Int
defaultPort = 5000
-- | Default store directory.
defaultStoreRoot :: FilePath
defaultStoreRoot = "./nix-cache"
-- | Environment variable for the write API key.
apiKeyEnvVar :: String
apiKeyEnvVar = "CACHE_API_KEY"
-- | Environment variable for the signing key file path.
signingKeyEnvVar :: String
signingKeyEnvVar = "SIGNING_KEY_FILE"
-- | Environment variable to disable request logging.
requestLogEnvVar :: String
requestLogEnvVar = "LOG_REQUESTS"
-- | Maximum allowed request body size (100 MB).
maxBodySize :: Int
maxBodySize = 100 * 1024 * 1024
-- ---------------------------------------------------------------------------
-- Server configuration
-- ---------------------------------------------------------------------------
-- | Runtime server configuration.
data Config = Config
{ cfgStore :: !FileStore,
cfgApiKey :: !(Maybe BS.ByteString),
cfgSigningKey :: !(Maybe SecretKey)
}
-- ---------------------------------------------------------------------------
-- Main
-- ---------------------------------------------------------------------------
main :: IO ()
main = do
args <- getArgs
portEnv <- lookupEnv "PORT"
storeEnv <- lookupEnv "NIX_CACHE_DIR"
apiKeyEnv <- lookupEnv apiKeyEnvVar
sigKeyPath <- lookupEnv signingKeyEnvVar
logRequestsEnv <- lookupEnv requestLogEnvVar
let port = case args of
("--port" : p : _) -> fromMaybe defaultPort (readMaybe p)
_ -> maybe defaultPort (fromMaybe defaultPort . readMaybe) portEnv
storeRoot = case args of
("--store" : s : _) -> s
_ -> fromMaybe defaultStoreRoot storeEnv
store <- newFileStore storeRoot
sigKey <- loadSigningKey sigKeyPath
let cfg =
Config
{ cfgStore = store,
cfgApiKey = TE.encodeUtf8 . T.pack <$> apiKeyEnv,
cfgSigningKey = sigKey
}
let logRequests = logRequestsEnv /= Just "0"
requestLogger = if logRequests then logStdout else id
putStrLn ("nova-cache-server listening on port " ++ show port)
putStrLn ("store root: " ++ storeRoot)
putStrLn ("signing: " ++ maybe "disabled" (const "enabled") sigKey)
putStrLn ("write auth: " ++ maybe "disabled (open writes!)" (const "enabled") (cfgApiKey cfg))
putStrLn ("request logging: " ++ if logRequests then "enabled" else "disabled (LOG_REQUESTS=0)")
Warp.run port (requestLogger (app cfg))
-- | Load a signing key from a file, if configured.
loadSigningKey :: Maybe FilePath -> IO (Maybe SecretKey)
loadSigningKey Nothing = pure Nothing
loadSigningKey (Just path) = do
raw <- BS.readFile path
case first show (TE.decodeUtf8' raw) >>= parseSecretKey . T.strip of
Left err -> do
hPutStrLn stderr ("WARNING: failed to load signing key: " ++ err)
pure Nothing
Right sk -> pure (Just sk)
-- ---------------------------------------------------------------------------
-- WAI application
-- ---------------------------------------------------------------------------
-- | WAI application implementing the Nix binary cache HTTP protocol.
app :: Config -> Application
app cfg req respond = case (requestMethod req, pathInfo req) of
-- GET /nix-cache-info
("GET", ["nix-cache-info"]) ->
respond (responseLBS HTTP.status200 textHeaders (BL.fromStrict (renderCacheInfo (cfgStore cfg))))
-- GET /narinfo-hashes
("GET", ["narinfo-hashes"]) -> do
hashes <- listNarInfoHashes (cfgStore cfg)
let body = TE.encodeUtf8 (T.unlines hashes)
respond (responseLBS HTTP.status200 textHeaders (BL.fromStrict body))
-- GET /<hash>.narinfo
("GET", [hashNarinfo])
| Just hashKey <- T.stripSuffix ".narinfo" hashNarinfo -> do
result <- readNarInfo (cfgStore cfg) hashKey
case result of
Just content ->
respond (responseLBS HTTP.status200 narInfoHeaders (BL.fromStrict content))
Nothing ->
respond notFound
-- GET /nar/<file>
("GET", ["nar", fileName]) -> do
result <- readNar (cfgStore cfg) fileName
case result of
Just content ->
respond (responseLBS HTTP.status200 octetHeaders (BL.fromStrict content))
Nothing ->
respond notFound
-- PUT /<hash>.narinfo (auth required, validated)
("PUT", [hashNarinfo])
| Just hashKey <- T.stripSuffix ".narinfo" hashNarinfo ->
requireAuth cfg req respond $
withLimitedBody req respond $ \body ->
case decodeAndValidate body of
Left err -> do
logWarn req ("INVALID: " <> T.unpack err)
respond (badRequest err)
Right ni -> do
signed <- signNarInfo (cfgSigningKey cfg) ni
ok <- writeNarInfo (cfgStore cfg) hashKey signed
if ok
then respond (responseLBS HTTP.status200 textHeaders "ok")
else do
logWarn req "BADPATH"
respond (badRequest "invalid path")
-- PUT /nar/<file> (auth required)
("PUT", ["nar", fileName]) ->
requireAuth cfg req respond $
withLimitedBody req respond $ \body -> do
ok <- writeNar (cfgStore cfg) fileName body
if ok
then respond (responseLBS HTTP.status200 textHeaders "ok")
else do
logWarn req "BADPATH"
respond (badRequest "invalid path")
-- Fallback
_ ->
respond notFound
-- ---------------------------------------------------------------------------
-- Validation pipeline
-- ---------------------------------------------------------------------------
-- | Decode a raw narinfo body and validate it in a single pure pipeline.
--
-- Composes UTF-8 decoding, narinfo parsing, and field validation. Returns
-- the validated 'NarInfo' on success, or a user-facing error message.
decodeAndValidate :: BS.ByteString -> Either Text NarInfo
decodeAndValidate body = do
decoded <- first (const "request body is not valid UTF-8") (TE.decodeUtf8' body)
ni <- first T.pack (parseNarInfo decoded)
first (T.unlines . map (T.pack . show)) (validateNarInfo ni)
-- ---------------------------------------------------------------------------
-- Request body limiting
-- ---------------------------------------------------------------------------
-- | Read the request body, rejecting payloads over 'maxBodySize'.
--
-- Returns 'Nothing' if the declared @Content-Length@ exceeds the limit.
-- For chunked transfers the body is read and checked after the fact.
readBodyLimited :: Request -> IO (Maybe BS.ByteString)
readBodyLimited req = case requestBodyLength req of
KnownLength len
| len > fromIntegral maxBodySize -> pure Nothing
_ -> do
body <- BL.toStrict <$> strictRequestBody req
if BS.length body > maxBodySize
then pure Nothing
else pure (Just body)
-- | Run an action with the limited request body, responding 413 if too large.
withLimitedBody :: Request -> (Response -> IO ResponseReceived) -> (BS.ByteString -> IO ResponseReceived) -> IO ResponseReceived
withLimitedBody req respond action = do
bodyResult <- readBodyLimited req
case bodyResult of
Nothing -> do
logWarn req "OVERLIMIT"
respond (responseLBS HTTP.status413 textHeaders "request body too large")
Just body -> action body
-- ---------------------------------------------------------------------------
-- Auth
-- ---------------------------------------------------------------------------
-- | Gate a handler behind API key authentication.
--
-- If no key is configured, all writes are permitted (open mode).
-- Otherwise the request must carry @Authorization: Bearer \<key\>@.
-- Uses constant-time comparison to prevent timing attacks.
requireAuth :: Config -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived -> IO ResponseReceived
requireAuth cfg req respond action = case cfgApiKey cfg of
Nothing -> action
Just expected ->
let provided = lookup HTTP.hAuthorization (requestHeaders req)
expectedHeader = "Bearer " <> expected
in if maybe False (constEq expectedHeader) provided
then action
else do
logWarn req "REJECTED"
respond (responseLBS HTTP.status401 textHeaders "unauthorized")
-- ---------------------------------------------------------------------------
-- Signing
-- ---------------------------------------------------------------------------
-- | Sign a validated 'NarInfo' if a signing key is configured.
--
-- Logs a warning to stderr if signing fails, then returns the unsigned
-- rendering so the upload is not rejected.
signNarInfo :: Maybe SecretKey -> NarInfo -> IO BS.ByteString
signNarInfo Nothing ni = pure (renderNarInfoBytes ni)
signNarInfo (Just sk) ni = case sign sk ni of
Left err -> do
hPutStrLn stderr ("WARNING: signNarInfo: sign failed: " ++ err)
pure (renderNarInfoBytes ni)
Right sig ->
let signed = ni {niSigs = niSigs ni ++ [sig]}
in pure (renderNarInfoBytes signed)
-- | Render a 'NarInfo' to its UTF-8 encoded wire format.
renderNarInfoBytes :: NarInfo -> BS.ByteString
renderNarInfoBytes = TE.encodeUtf8 . renderNarInfo
-- ---------------------------------------------------------------------------
-- Logging
-- ---------------------------------------------------------------------------
-- | Log a server-side warning to stderr with request context.
logWarn :: Request -> String -> IO ()
logWarn req msg =
hPutStrLn stderr $
msg
<> " "
<> BS8.unpack (requestMethod req)
<> " /"
<> T.unpack (T.intercalate "/" (pathInfo req))
-- ---------------------------------------------------------------------------
-- Response helpers
-- ---------------------------------------------------------------------------
-- | Render the nix-cache-info response body.
renderCacheInfo :: FileStore -> BS.ByteString
renderCacheInfo store =
let (storeDir, wantMassQuery, priority) = getCacheInfo store
in TE.encodeUtf8 $
T.unlines
[ "StoreDir: " <> storeDir,
"WantMassQuery: " <> boolText wantMassQuery,
"Priority: " <> T.pack (show priority)
]
-- | Render a Bool as @1@ or @0@.
boolText :: Bool -> Text
boolText True = "1"
boolText False = "0"
-- | 404 Not Found response.
notFound :: Response
notFound = responseLBS HTTP.status404 textHeaders "not found"
-- | 400 Bad Request with a text error message.
badRequest :: Text -> Response
badRequest msg = responseLBS HTTP.status400 textHeaders (BL.fromStrict (TE.encodeUtf8 msg))
-- | Content-Type: text/plain headers.
textHeaders :: HTTP.ResponseHeaders
textHeaders = [(HTTP.hContentType, "text/plain")]
-- | Content-Type: application/x-nix-narinfo headers.
-- Narinfo files are content-addressed (keyed by store path hash) and
-- immutable once written, so they are safe to cache indefinitely at the
-- CDN edge.
narInfoHeaders :: HTTP.ResponseHeaders
narInfoHeaders =
[ (HTTP.hContentType, "text/x-nix-narinfo"),
(HTTP.hCacheControl, "public, max-age=31536000, immutable")
]
-- | Content-Type: application/octet-stream headers.
-- NAR files are content-addressed (keyed by content hash) and immutable
-- once written, so they are safe to cache indefinitely at the CDN edge.
octetHeaders :: HTTP.ResponseHeaders
octetHeaders =
[ (HTTP.hContentType, "application/octet-stream"),
(HTTP.hCacheControl, "public, max-age=31536000, immutable")
]