packages feed

kitchen-sink-0.2.0.0: src/KitchenSink/Engine/Acme.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}

{- | Certificates obtained from an ACME server (e.g. Let's Encrypt) with
TLS-ALPN-01 challenges (RFC-8737), for the HTTPS server of @serve@ and
@multisite@.

The ACME server validates a domain by opening a TLS connection to port 443
with the @acme-tls/1@ protocol: the HTTPS server answers it from a
'TlsAlpn01Store' (see 'withAcmeChallenges'), so no port 80 is needed. A
certificate manager (from acme-not-a-joke-warp) obtains one certificate per
domain when the daemon starts, stores keys and certificates on disk, renews
them before they expire, and hands them to the running server, which presents
them at the next handshake without a restart.
-}
module KitchenSink.Engine.Acme (
    AcmeOptions (..),
    Acme,
    acmeDomains,
    setupAcme,
    setupAcmeOrExit,
    runAcme,
    acmeTlsSettings,
    withAcmeChallenges,
    acmeSniCredentials,
    servesDomain,
    resolveDirectory,
    defaultStateDir,
) where

import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (fromMaybe)
import Data.Text qualified as Text
import Network.TLS qualified as TLS
import Network.Wai.Handler.WarpTLS qualified as WarpTLS
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)
import Prelude ((||))

import Acme.NotAJoke.LetsEncrypt (letsencryptv2, staging_letsencryptv2)
import Acme.NotAJoke.Wai.TlsAlpn01 (TlsAlpn01Store, newTlsAlpn01Store, withTlsAlpn01)
import Acme.NotAJoke.Wai.WarpTLS (CertificateStore, liveTlsSettings, newCertificateStore, sniCredentials)
import Acme.NotAJoke.Warp.CertificateManager (
    CertificateManager,
    acmeTlsAlpn01Issuer,
    defaultAcmeSettings,
    defaultManagerSettings,
    newCertificateManager,
    runCertificateManager,
 )

import KitchenSink.Prelude

-- | How to reach the ACME server, shared by every domain of a daemon.
data AcmeOptions = AcmeOptions
    { directory :: Maybe Text
    {- ^ @letsencrypt@, @letsencrypt-staging@, or the URL of the directory of
    another ACME server. Required: there is no default, so that nobody gets
    staging certificates (untrusted) or hits production rate limits by
    accident.
    -}
    , contacts :: [Text]
    -- ^ contacts of the ACME account, @mailto:@ is added to bare addresses
    , stateDir :: Maybe FilePath
    -- ^ where the account key, the keys and the certificates live, see 'defaultStateDir'
    }
    deriving (Show)

-- | The running parts: challenge store, certificate store and their manager.
data Acme = Acme
    { domains :: NonEmpty Text
    , challenges :: TlsAlpn01Store
    , certificates :: CertificateStore
    , manager :: CertificateManager
    }

-- | The domains certificates are obtained for, lower-cased.
acmeDomains :: Acme -> NonEmpty Text
acmeDomains acme = acme.domains

-- | Where state is kept when @--acmeStateDir@ is not given (relative to the working directory).
defaultStateDir :: FilePath
defaultStateDir = "acme-state"

{- | Prepares a manager for some domains: one certificate per domain, the
first one also being presented to clients asking for no name or an unknown
one. Nothing is requested before 'runAcme'.
-}
setupAcme :: AcmeOptions -> NonEmpty Text -> IO (Either String Acme)
setupAcme opts names =
    case resolveDirectory =<< maybe (Left missingDirectory) Right opts.directory of
        Left err -> pure (Left err)
        Right url -> do
            let dir = fromMaybe defaultStateDir opts.stateDir
            let lowered = NonEmpty.nub (fmap Text.toLower names)
            store <- newTlsAlpn01Store
            certs <- newCertificateStore
            let acmeSettings = defaultAcmeSettings url (dir </> "account.jwk") (fmap asContact opts.contacts)
            created <- newCertificateManager (defaultManagerSettings lowered dir) (acmeTlsAlpn01Issuer acmeSettings store) certs
            pure $ fmap (Acme lowered store certs) created
  where
    missingDirectory =
        "ACME needs a directory: --acmeDirectory letsencrypt, letsencrypt-staging, or the URL of an ACME directory"

    asContact :: Text -> Text
    asContact c
        | ":" `Text.isInfixOf` c = c
        | otherwise = "mailto:" <> c

-- | 'setupAcme', printing the error and exiting on failure.
setupAcmeOrExit :: AcmeOptions -> NonEmpty Text -> IO Acme
setupAcmeOrExit opts names = do
    res <- setupAcme opts names
    case res of
        Left err -> do
            hPutStrLn stderr ("acme: " <> err)
            exitFailure
        Right acme -> do
            hPutStrLn stderr ("acme: managing certificates for " <> Text.unpack (Text.intercalate ", " (NonEmpty.toList acme.domains)) <> " (tls-alpn-01)")
            pure acme

{- | The base URL acme-not-a-joke wants (ending with @/@, @directory@ gets
appended to it) from a well-known name or a directory URL.
-}
resolveDirectory :: Text -> Either String Text
resolveDirectory txt =
    case Text.strip txt of
        "letsencrypt" -> Right letsencryptv2
        "letsencrypt-staging" -> Right staging_letsencryptv2
        url
            | "https://" `Text.isPrefixOf` url || "http://" `Text.isPrefixOf` url ->
                Right $ case Text.stripSuffix "directory" url of
                    Just base | "/" `Text.isSuffixOf` base -> base
                    _ | "/" `Text.isSuffixOf` url -> url
                    _ -> url <> "/"
            | otherwise ->
                Left ("unknown ACME directory " <> show url <> ": use letsencrypt, letsencrypt-staging, or an http(s) URL")

{- | Obtains, stores and renews the certificates, forever. Failures are
logged on stderr and retried later; they never stop the daemon.
-}
runAcme :: Acme -> IO ()
runAcme acme = runCertificateManager acme.manager

{- | warp-tls settings presenting the managed certificates and answering the
TLS-ALPN-01 challenges: what @serve@ uses.
-}
acmeTlsSettings :: Acme -> WarpTLS.TLSSettings
acmeTlsSettings acme = withAcmeChallenges acme (liveTlsSettings acme.certificates)

{- | Makes settings answer the TLS-ALPN-01 challenges. Apply it last: it
takes over the protocol choice and wraps the server name hook already set.
-}
withAcmeChallenges :: Acme -> WarpTLS.TLSSettings -> WarpTLS.TLSSettings
withAcmeChallenges acme = withTlsAlpn01 acme.challenges

{- | The managed credential for a server name: the one of that name, else the
one of the first domain (possibly none yet, until it is obtained).
-}
acmeSniCredentials :: Acme -> Maybe TLS.HostName -> IO TLS.Credentials
acmeSniCredentials acme = sniCredentials acme.certificates

-- | Whether a server name is one of the managed domains (case-insensitive).
servesDomain :: Acme -> TLS.HostName -> Bool
servesDomain acme name = Text.toLower (Text.pack name) `elem` acme.domains