packages feed

acme-not-a-joke-warp-0.1.0.0: src/Acme/NotAJoke/Warp/CertificateManager.hs

{- | Keeps the certificates of a TLS server valid, unattended.

A 'CertificateManager' looks after one certificate per domain name. For each
domain it:

* loads the certificate stored on disk by a previous run, if there is one;
* asks an 'Issuer' for a certificate when there is none, or when the stored one
  is due for renewal (see 'renewAt');
* writes the new certificate on disk, and hands it to a
  'Acme.NotAJoke.Wai.WarpTLS.CertificateStore' so that a running warp-tls
  server presents it to the next connection, without a restart;
* tries again later, with increasing delays, when the 'Issuer' fails (see
  'retryDelay').

'runCertificateManager' is the loop doing so forever, 'checkCertificates' is
a single pass of this loop.

The manager never throws for a failed issuance: errors of the 'Issuer',
whether returned or thrown (e.g., the @HttpException@ of a network failure),
and errors while reading or writing files are reported to 'onEvent' and the
certificate is tried again later. Meanwhile, the server keeps presenting the
certificate it has.

= Where things are stored

Everything is under the 'stateDirectory':

* @{domain}\/key.pem@: the private key of the certificate, created on the
  first run (see 'generateKey') and reused for renewals
* @{domain}\/certificate.pem@: the certificate chain, leaf certificate first

These are the formats that warp-tls, nginx, haproxy etc. read. Files are
replaced atomically (written aside, then renamed). The directory holds private
keys: restrict who can read it.

The ACME account key of 'acmeIssuer' is stored where 'accountKeyPath' says.
-}
module Acme.NotAJoke.Warp.CertificateManager (
    -- * manager
    CertificateManager,
    newCertificateManager,
    runCertificateManager,
    checkCertificates,

    -- * settings
    Domain,
    ManagerSettings (..),
    defaultManagerSettings,
    keyPath,
    certificatePath,

    -- * renewal
    Validity (..),
    renewAfterTwoThirds,
    renewBefore,
    exponentialRetry,

    -- * events
    Event (..),
    showEvent,
    logEvent,

    -- * issuers
    Issuer,
    AcmeSettings (..),
    defaultAcmeSettings,
    acmeIssuer,
    acmeTlsAlpn01Issuer,
) where

import Control.Concurrent (threadDelay)
import Control.Exception (Exception, SomeAsyncException (..), SomeException, catch, displayException, fromException, throwIO)
import Control.Monad (forever, unless, void, when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
import qualified Crypto.PubKey.RSA as RSA
import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Lazy as LByteString
import Data.Char (isAsciiLower, isDigit)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NonEmpty
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime)
import qualified Data.X509 as X509
import Network.TLS (Credential)
import System.Directory (createDirectoryIfMissing, doesFileExist, renameFile)
import System.FilePath (takeDirectory, (<.>), (</>))
import System.IO (hPutStrLn, stderr)
import qualified Time.Types as Hourglass

import Acme.NotAJoke.Api.Account (Contact, fetchAccount1)
import Acme.NotAJoke.Api.Certificate (PEM (..), readPEM)
import Acme.NotAJoke.Api.Endpoint (AcmeError, BaseUrl, problemDetail, readProblem)
import Acme.NotAJoke.Api.Order (OrderIdentifier (..), OrderType (..), createOrder)
import Acme.NotAJoke.CertManagement (createCSR, decodeCertificateChain)
import Acme.NotAJoke.Dancer (AcmeDancer (..), DanceStep (..))
import Acme.NotAJoke.KeyManagement (encodeRSAKeyPEM, genRSAKey4096, loadOrCreateJWKFile, loadRSAKeyPEM)
import Acme.NotAJoke.Wai.Http01 (ChallengeStore, runAcmeDance_http01_wai)
import Acme.NotAJoke.Wai.TlsAlpn01 (TlsAlpn01Store, runAcmeDance_tlsalpn01_wai)
import Acme.NotAJoke.Wai.WarpTLS (CertificateStore, setCredential, setCredentialFor)

{- | A DNS name, in ASCII form (i.e., punycode-encoded for internationalized
names). Wildcards are not supported.
-}
type Domain = Text

{- | Gets a certificate for a domain and the private key of the certificate.

Returns the content of a PEM file: the certificate chain, leaf certificate
first. See 'acmeIssuer' for the ACME one.
-}
type Issuer = Domain -> RSA.PrivateKey -> IO (Either String ByteString)

-- | The period during which a certificate is valid.
data Validity = Validity
    { notBefore :: UTCTime
    , notAfter :: UTCTime
    }
    deriving (Show, Eq)

-- | What a manager does and when.
data ManagerSettings = ManagerSettings
    { domains :: NonEmpty Domain
    {- ^ the names to get a certificate for: one certificate per name. The
    certificate of the first name also is the default one, presented to
    clients asking for another name (or for none).
    -}
    , stateDirectory :: FilePath
    -- ^ where keys and certificates are stored, created if missing
    , renewAt :: Validity -> UTCTime
    -- ^ when to renew a certificate, defaults to 'renewAfterTwoThirds'
    , retryDelay :: Int -> NominalDiffTime
    {- ^ how long to wait after the n-th failure in a row (starting at 1),
    defaults to 'exponentialRetry'
    -}
    , checkInterval :: NominalDiffTime
    {- ^ the longest 'runCertificateManager' sleeps between two looks at the
    clock, defaults to one hour
    -}
    , generateKey :: IO RSA.PrivateKey
    {- ^ generates the private key of a certificate, defaults to
    'genRSAKey4096'
    -}
    , onEvent :: Event -> IO ()
    -- ^ called for each 'Event', defaults to 'logEvent'
    , getTime :: IO UTCTime
    -- ^ the clock, defaults to 'getCurrentTime'
    }

-- | Settings with the defaults documented in 'ManagerSettings'.
defaultManagerSettings :: NonEmpty Domain -> FilePath -> ManagerSettings
defaultManagerSettings names dir =
    ManagerSettings
        { domains = names
        , stateDirectory = dir
        , renewAt = renewAfterTwoThirds
        , retryDelay = exponentialRetry
        , checkInterval = 3600
        , generateKey = genRSAKey4096
        , onEvent = logEvent
        , getTime = getCurrentTime
        }

{- | Renews when two thirds of the validity period have elapsed (e.g., 30 days
before the end for a certificate valid for 90 days).
-}
renewAfterTwoThirds :: Validity -> UTCTime
renewAfterTwoThirds validity =
    addUTCTime (2 * diffUTCTime validity.notAfter validity.notBefore / 3) validity.notBefore

-- | Renews a fixed duration before the end of the validity period.
renewBefore :: NominalDiffTime -> Validity -> UTCTime
renewBefore margin validity = addUTCTime (negate margin) validity.notAfter

{- | Two minutes after the first failure, then twice as long after each
failure, up to six hours.

ACME providers limit the number of failed validations (e.g., five per hour
and per name at Let's Encrypt): do not retry much faster.
-}
exponentialRetry :: Int -> NominalDiffTime
exponentialRetry n = min (6 * 3600) (120 * 2 ^ (max 0 (min 16 (n - 1))))

-- | What happens to the certificates of a manager.
data Event
    = -- | a certificate stored by a previous run is served
      CertificateLoaded Domain Validity
    | -- | the stored key or certificate cannot be used, with the reason: a new certificate is requested
      StoredCertificateUnusable Domain String
    | -- | the issuer is asked for a certificate
      CertificateRequested Domain
    | -- | a new certificate is stored and served
      CertificateObtained Domain Validity
    | -- | no certificate, with the reason and the time of the next attempt
      CertificateFailed Domain String UTCTime
    deriving (Show, Eq)

-- | A line of log for an event.
showEvent :: Event -> String
showEvent event =
    case event of
        CertificateLoaded domain validity ->
            line domain ("serving the stored certificate, valid until " <> show validity.notAfter)
        StoredCertificateUnusable domain reason ->
            line domain ("cannot use the stored certificate: " <> reason)
        CertificateRequested domain ->
            line domain "requesting a certificate"
        CertificateObtained domain validity ->
            line domain ("serving a new certificate, valid until " <> show validity.notAfter)
        CertificateFailed domain reason next ->
            line domain ("no certificate, next attempt at " <> show next <> ": " <> reason)
  where
    line domain msg = "acme-not-a-joke-warp: " <> Text.unpack domain <> ": " <> msg

-- | Prints the event on stderr.
logEvent :: Event -> IO ()
logEvent = hPutStrLn stderr . showEvent

-- | Path of the private key of the certificate of a domain.
keyPath :: ManagerSettings -> Domain -> FilePath
keyPath cfg domain = cfg.stateDirectory </> Text.unpack domain </> "key.pem"

-- | Path of the certificate chain of a domain.
certificatePath :: ManagerSettings -> Domain -> FilePath
certificatePath cfg domain = cfg.stateDirectory </> Text.unpack domain </> "certificate.pem"

data DomainState = DomainState
    { due :: UTCTime
    -- ^ nothing to do before this time
    , failures :: Int
    -- ^ failures in a row
    }

-- | See 'newCertificateManager'.
data CertificateManager = CertificateManager
    { settings :: ManagerSettings
    , issuer :: Issuer
    , store :: CertificateStore
    , states :: IORef (Map Domain DomainState)
    }

{- | Creates a manager getting its certificates from an issuer and handing
them to a store.

Nothing happens until 'runCertificateManager' or 'checkCertificates' is
called. The store is the one given to
'Acme.NotAJoke.Wai.WarpTLS.liveTlsSettings'.

Domain names are lower-cased. Fails on names which are not plain host names
(they are used as directory names, and wildcards cannot be validated with
HTTP-01 or TLS-ALPN-01).
-}
newCertificateManager :: ManagerSettings -> Issuer -> CertificateStore -> IO (Either String CertificateManager)
newCertificateManager cfg getCertificate certificates =
    case traverse hostName (NonEmpty.nub (NonEmpty.map Text.toLower cfg.domains)) of
        Left err -> pure (Left err)
        Right names -> do
            ref <- newIORef Map.empty
            pure $ Right $ CertificateManager cfg{domains = names} getCertificate certificates ref
  where
    hostName :: Domain -> Either String Domain
    hostName name
        | validDomain name = Right name
        | otherwise = Left ("not a host name: " <> show name)

validDomain :: Domain -> Bool
validDomain name =
    not (Text.null name)
        && Text.length name <= 253
        && all validLabel (Text.splitOn "." name)
  where
    validLabel label =
        not (Text.null label)
            && Text.all (\c -> isAsciiLower c || isDigit c || c == '-') label

{- | Checks the certificates forever: sleeps until the next certificate is due
(for renewal, or for another attempt after a failure), up to 'checkInterval'.

Run it in its own thread, next to the servers.
-}
runCertificateManager :: CertificateManager -> IO a
runCertificateManager manager = forever $ do
    next <- checkCertificates manager
    now <- manager.settings.getTime
    let wait = max 1 (min manager.settings.checkInterval (diffUTCTime next now))
    threadDelay (ceiling (wait * 1000000))

{- | Checks every domain once: loads, obtains or renews the certificates which
are due, and leaves the other ones alone.

Returns when to call it again: the earliest time at which a certificate is due.
Failures are reported to 'onEvent' rather than thrown.
-}
checkCertificates :: CertificateManager -> IO UTCTime
checkCertificates manager = do
    let first :| others = manager.settings.domains
    dues <- (:|) <$> checkDomain manager True first <*> traverse (checkDomain manager False) others
    pure (minimum dues)

checkDomain :: CertificateManager -> Bool -> Domain -> IO UTCTime
checkDomain manager isDefault domain = do
    now <- manager.settings.getTime
    known <- Map.lookup domain <$> readIORef manager.states
    case known of
        Just st | st.due > now -> pure st.due
        _ -> do
            outcome <- guarded (ensureCertificate manager isDefault domain now)
            let st = nextState now (maybe 0 (.failures) known) outcome
            atomicModifyIORef' manager.states (\m -> (Map.insert domain st m, ()))
            case outcome of
                Left reason -> emit manager (CertificateFailed domain reason st.due)
                Right _ -> pure ()
            pure st.due
  where
    nextState :: UTCTime -> Int -> Either String Validity -> DomainState
    nextState now failed outcome =
        case outcome of
            Left _ -> DomainState (addUTCTime (retryAfter (failed + 1)) now) (failed + 1)
            -- a certificate which already is due for renewal is not requested
            -- again right away
            Right validity -> DomainState (max (manager.settings.renewAt validity) (addUTCTime (retryAfter 1) now)) 0

    retryAfter :: Int -> NominalDiffTime
    retryAfter = max 1 . manager.settings.retryDelay

{- | Serves a certificate which is not due for renewal: the stored one, or a
new one from the issuer.
-}
ensureCertificate :: CertificateManager -> Bool -> Domain -> UTCTime -> IO (Either String Validity)
ensureCertificate manager isDefault domain now = runExceptT $ do
    liftIO $ createDirectoryIfMissing True (takeDirectory keyFile)
    key <- ExceptT $ loadOrCreateKey manager.settings.generateKey keyFile
    stored <- liftIO $ loadStored key certFile
    case stored of
        Right (Just (cred, validity))
            | now < manager.settings.renewAt validity -> liftIO $ do
                install cred
                emit manager (CertificateLoaded domain validity)
                pure validity
            | now < validity.notAfter -> do
                -- still good to serve while the new one is requested
                liftIO $ install cred
                request key
        Right _ -> request key
        Left reason -> do
            liftIO $ emit manager (StoredCertificateUnusable domain reason)
            request key
  where
    keyFile = keyPath manager.settings domain
    certFile = certificatePath manager.settings domain

    install :: Credential -> IO ()
    install cred = do
        setCredentialFor manager.store (Text.unpack domain) cred
        when isDefault $ setCredential manager.store cred

    request :: RSA.PrivateKey -> ExceptT String IO Validity
    request key = do
        liftIO $ emit manager (CertificateRequested domain)
        pem <- ExceptT $ manager.issuer domain key
        (cred, validity) <- ExceptT $ pure $ readCredential key pem
        liftIO $ do
            writeFileAtomic certFile pem
            install cred
            emit manager (CertificateObtained domain validity)
        pure validity

loadOrCreateKey :: IO RSA.PrivateKey -> FilePath -> IO (Either String RSA.PrivateKey)
loadOrCreateKey generate path = do
    exists <- doesFileExist path
    if exists
        then loadRSAKeyPEM path
        else do
            key <- generate
            writeFileAtomic path (encodeRSAKeyPEM key)
            pure (Right key)

-- | The stored certificate, if there is one.
loadStored :: RSA.PrivateKey -> FilePath -> IO (Either String (Maybe (Credential, Validity)))
loadStored key path = do
    exists <- doesFileExist path
    if exists
        then fmap Just . readCredential key <$> ByteString.readFile path
        else pure (Right Nothing)

-- | Reads a certificate chain (PEM), which must be for the given key.
readCredential :: RSA.PrivateKey -> ByteString -> Either String (Credential, Validity)
readCredential key pem = do
    chain <- decodeCertificateChain pem
    case chain of
        [] -> Left "no certificate in the PEM content"
        (leaf : _) -> do
            let cert = X509.getCertificate leaf
            unless (X509.certPubKey cert == X509.PubKeyRSA (RSA.private_pub key)) $
                Left "the certificate is not for the stored key"
            let (start, end) = X509.certValidity cert
            pure ((X509.CertificateChain chain, X509.PrivKeyRSA key), Validity (toUTCTime start) (toUTCTime end))

toUTCTime :: Hourglass.DateTime -> UTCTime
toUTCTime (Hourglass.DateTime (Hourglass.Date year month day) (Hourglass.TimeOfDay hours minutes seconds _)) =
    UTCTime
        (fromGregorian (fromIntegral year) (fromEnum month + 1) day)
        (fromIntegral hours * 3600 + fromIntegral minutes * 60 + fromIntegral seconds)

-- | Writes aside then renames, so that readers never see half a file.
writeFileAtomic :: FilePath -> ByteString -> IO ()
writeFileAtomic path bytes = do
    let tmp = path <.> "tmp"
    ByteString.writeFile tmp bytes
    renameFile tmp path

-- | Calls the event handler, which must not break the manager.
emit :: CertificateManager -> Event -> IO ()
emit manager event =
    void $ guarded (Right <$> manager.settings.onEvent event)

{- | Turns synchronous exceptions into errors. Asynchronous ones (e.g., the
thread being killed) are thrown again.
-}
guarded :: IO (Either String a) -> IO (Either String a)
guarded action = action `catch` handler
  where
    handler :: SomeException -> IO (Either String a)
    handler e =
        case fromException e of
            Just (SomeAsyncException _) -> throwIO e
            Nothing -> pure (Left (displayException e))

-- | How to get certificates from an ACME server.
data AcmeSettings = AcmeSettings
    { acmeUrl :: BaseUrl
    -- ^ the ACME server, e.g. 'Acme.NotAJoke.LetsEncrypt.letsencryptv2'
    , accountKeyPath :: FilePath
    {- ^ where the account key (a JWK) is stored. The key is created on the
    first run, and so is the account.
    -}
    , contacts :: [Contact]
    -- ^ contacts of the account, e.g. @["mailto:certmaster\@example.com"]@
    , pollDelay :: Int -> Int
    {- ^ how long to wait (in microseconds) before the n-th poll of an order
    (starting at 0), defaults to n seconds capped at ten seconds
    -}
    , maxPolls :: Int
    -- ^ how many times an order is polled before giving up, defaults to 60
    }

{- | Settings with the defaults documented in 'AcmeSettings'.

Using an ACME server means agreeing to its terms of service: 'acmeIssuer'
says so when it creates the account.
-}
defaultAcmeSettings :: BaseUrl -> FilePath -> [Contact] -> AcmeSettings
defaultAcmeSettings url path contactUrls =
    AcmeSettings
        { acmeUrl = url
        , accountKeyPath = path
        , contacts = contactUrls
        , pollDelay = \n -> 1000000 * min 10 n
        , maxPolls = 60
        }

{- | Gets certificates from an ACME server with HTTP-01 challenges.

The challenges are served from the store by
'Acme.NotAJoke.Wai.Http01.http01Middleware', which must be running on port 80
of the domain and reachable by the ACME server.

Each call is a whole ACME dance, with a new CSR built in memory.
-}
acmeIssuer :: AcmeSettings -> ChallengeStore -> Issuer
acmeIssuer acme challenges = acmeIssuerWith (runAcmeDance_http01_wai challenges) acme

{- | Gets certificates from an ACME server with TLS-ALPN-01 challenges
(RFC-8737), which need no port 80.

The challenges are answered from the store by the HTTPS server, whose TLS
settings must be wrapped with 'Acme.NotAJoke.Wai.TlsAlpn01.withTlsAlpn01',
running on port 443 of the domain and reachable by the ACME server.

Each call is a whole ACME dance, with a new CSR built in memory. The
challenge certificate has a throw-away RSA-2048 key.
-}
acmeTlsAlpn01Issuer :: AcmeSettings -> TlsAlpn01Store -> Issuer
acmeTlsAlpn01Issuer acme challenges = acmeIssuerWith (runAcmeDance_tlsalpn01_wai challenges) acme

-- | An issuer running a dance.
acmeIssuerWith :: (AcmeDancer -> IO ()) -> AcmeSettings -> Issuer
acmeIssuerWith dance acme domain key = runExceptT $ do
    liftIO $ createDirectoryIfMissing True (takeDirectory acme.accountKeyPath)
    accountKey <- ExceptT $ loadOrCreateJWKFile acme.accountKeyPath
    request <- ExceptT $ createCSR key (domain :| [])
    result <- liftIO $ newIORef (Left "the ACME dance stopped without a certificate")
    let handle step =
            case step of
                WaitingForValidation n
                    | n >= acme.maxPolls -> writeIORef result (Left "the order still is pending") >> throwIO PollingTimeout
                    | otherwise -> threadDelay (acme.pollDelay n)
                Done _ cert -> writeIORef result (Right (pemBytes (readPEM cert)))
                InvalidOrder _ -> writeIORef result (Left "the ACME server could not validate the challenge")
                OtherError err -> writeIORef result (Left (Text.unpack err))
                AcmeFailure err -> writeIORef result (Left (showAcmeError err))
                _ -> pure ()
    let dancer =
            AcmeDancer
                { baseUrl = acme.acmeUrl
                , accountJwk = accountKey
                , -- creates the account when the key has none yet
                  account = fetchAccount1 True False acme.contacts
                , csr = request
                , order = createOrder (Nothing, Nothing) [OrderIdentifier DNSOrder domain]
                , handleStep = handle
                }
    liftIO $ dance dancer `catch` \PollingTimeout -> pure ()
    ExceptT $ readIORef result
  where
    pemBytes (PEM bytes) = LByteString.toStrict bytes

-- | Stops a dance whose order never leaves the pending state.
data PollingTimeout = PollingTimeout
    deriving (Show)

instance Exception PollingTimeout

showAcmeError :: AcmeError -> String
showAcmeError err =
    case problemDetail =<< readProblem err of
        Just detail -> "the ACME server rejected a request: " <> Text.unpack detail
        Nothing -> show err