packages feed

warp-tls-simple-0.1.0.2: src/Network/Wai/Handler/WarpTLS/Simple.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}

{- |
Simplified TLS configuration for Warp servers with automatic certificate generation

This module provides a simplified interface for running Warp servers with TLS support,
including automatic self-signed certificate generation for development environments.

The main design goals are:

* __HTTPS by default__: TLS is enabled by default with automatic certificate generation
* __Zero-configuration development__: Works out of the box for local development
* __Production-ready__: Supports custom certificates for production deployment
* __CLI integration__: Provides 'tlsConfigParser' for command-line argument parsing

= Quick Start

@
import Network.Wai.Handler.WarpTLS.Simple
import Network.Wai.Handler.Warp qualified as Warp

main :: IO ()
main = do
  let settings = Warp.defaultSettings & Warp.setPort 8080
  startWarpServer settings ".\/state" TLSAuto myWaiApplication
@

= Certificate Management

The module supports three TLS configurations:

* 'TLSAuto' - Automatically generates self-signed certificates (default)
* 'TLSExplicit' - Uses user-provided certificate and key files
* 'TLSDisabled' - Runs HTTP only (must be explicitly requested)

Auto-generated certificates include Subject Alternative Names (SAN) for:

* @localhost@ and the specified hostname
* Common local network IP ranges (@127.0.0.1@, @192.168.*.*@, @10.*.*.*@, etc.)
* IPv6 loopback (@::1@)

= CLI Integration

@
data MyAppConfig = MyAppConfig
  { port :: Int
  , tlsConfig :: TLSConfig
  }

myConfigParser :: Parser MyAppConfig
myConfigParser = MyAppConfig
  \<$\> option auto (long \"port\" \<\> value 8080)
  \<*\> tlsConfigParser
@
-}
module Network.Wai.Handler.WarpTLS.Simple (
  -- * TLS Configuration
  TLSConfig (..),

  -- * Server Functions
  tlsConfigResolve,
  startWarpServer,

  -- * CLI Integration
  tlsConfigParser,
) where

import Network.Wai (Application)
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Handler.WarpTLS qualified as WarpTLS
import Network.Wai.Handler.WarpTLS.Internal qualified as WarpTLS
import Options.Applicative
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath ((</>))
import System.Process (callProcess)
import System.Which (staticWhich)
import Text.Show (Show (..))

{- | Path to the `openssl` executable

This should be available in the PATH, thanks to Nix and `which` library.
-}
opensslBin :: FilePath
opensslBin = $(staticWhich "openssl")

{- | TLS configuration with HTTPS enabled by default

This type represents the three supported TLS modes for the Warp server:

[TLSDisabled] HTTP-only mode. Must be explicitly requested with @--no-https@ flag.
This is useful for development behind a reverse proxy or when TLS termination
happens elsewhere.

[TLSAuto] __Default mode__. Automatically generates self-signed certificates for HTTPS.
Certificates are stored in @\<stateDir\>\/tls\/@ and include SAN entries for local
development. This provides zero-configuration HTTPS for development environments.

[TLSExplicit] Production mode with user-provided certificates. Requires both
certificate and private key files specified via @--tls-cert@ and @--tls-key@ flags.

== Examples

Auto-generated certificates (default):
@
tlsConfig = TLSAuto
@

Custom certificates for production:
@
tlsSettings <- WarpTLS.tlsSettings "\/path\/to\/cert.pem" "\/path\/to\/key.pem"
tlsConfig = TLSExplicit tlsSettings
@

HTTP-only mode:
@
tlsConfig = TLSDisabled
@

The 'Show' instance provides debugging information including certificate details
for 'TLSExplicit' configurations.
-}
data TLSConfig
  = -- | No TLS - run HTTP only (explicit)
    TLSDisabled
  | -- | TLS with auto-generated certificates (default)
    TLSAuto
  | -- | TLS with user-provided certificate and key files
    TLSExplicit WarpTLS.TLSSettings

instance Show TLSConfig where
  show = \case
    TLSDisabled -> "TLSDisabled"
    TLSAuto -> "TLSAuto"
    TLSExplicit tlsSettings -> "TLSExplicit (user-provided certificates): " <> Text.Show.show (WarpTLS.getCertSettings tlsSettings)

{- | Command-line parser for TLS configuration

Provides @optparse-applicative@ integration with HTTPS enabled by default:

* @--no-https@ - Disable HTTPS
* @--tls-cert FILE --tls-key FILE@ - Use custom certificates
* Default - Auto-generate certificates

Both certificate options must be provided together.
-}
tlsConfigParser :: Parser TLSConfig
tlsConfigParser =
  noHttpsMode <|> tlsExplicitMode <|> defaultMode
  where
    noHttpsMode =
      flag'
        TLSDisabled
        ( long "no-https"
            <> help "Disable HTTPS and run HTTP server only"
        )

    tlsExplicitMode =
      fmap TLSExplicit . WarpTLS.tlsSettings
        <$> strOption
          ( long "tls-cert"
              <> metavar "TLS_CERT"
              <> help "Path to TLS certificate file (requires --tls-key)"
          )
        <*> strOption
          ( long "tls-key"
              <> metavar "TLS_KEY"
              <> help "Path to TLS private key file (requires --tls-cert)"
          )

    -- Default to auto-generation (HTTPS enabled by default)
    defaultMode = pure TLSAuto

{- | Resolve TLS configuration to WarpTLS settings

Converts a 'TLSConfig' to the appropriate 'WarpTLS.TLSSettings' for the Warp server:

* 'TLSDisabled' → 'Nothing' (HTTP mode)
* 'TLSAuto' → Auto-generates certificates in @stateDir\/tls\/@
* 'TLSExplicit' → Uses provided settings

For 'TLSAuto', certificates are generated once and reused on subsequent runs.
The hostname "localhost" is used for certificate generation.
-}
tlsConfigResolve :: FilePath -> TLSConfig -> IO (Maybe WarpTLS.TLSSettings)
tlsConfigResolve stateDir = \case
  TLSDisabled -> pure Nothing
  TLSAuto -> Just <$> ensureTLSSettings (stateDir </> "tls") "localhost"
  TLSExplicit tlsSettings -> pure (Just tlsSettings)

{- | Ensure TLS certificates exist for auto-generation mode
Returns TLSSettings configured with the certificate and key file paths
-}
ensureTLSSettings :: FilePath -> Text -> IO WarpTLS.TLSSettings
ensureTLSSettings certDir hostArg = do
  let (certPath, keyPath) = certPaths certDir

  certExists <- doesFileExist certPath
  keyExists <- doesFileExist keyPath

  if certExists && keyExists
    then do
      putTextLn $ "Using existing TLS certificates from " <> toText certDir <> "/"
    else do
      putTextLn "Generating TLS certificates for HTTPS support..."
      createDirectoryIfMissing True certDir
      generateCertificates certDir hostArg

  pure $ WarpTLS.tlsSettings certPath keyPath

-- | Helper function to construct certificate and key file paths from a directory
certPaths :: FilePath -> (FilePath, FilePath)
certPaths certDir =
  let certPath = certDir <> "/server.crt"
      keyPath = certDir <> "/server.key"
   in (certPath, keyPath)

-- | High-level certificate request configuration
data CertificateRequest = CertificateRequest
  { certSubject :: CertSubject
  , certValidityDays :: Int
  , certSANHosts :: [Text] -- Additional SAN hostnames
  , certSANIPs :: [Text] -- Additional SAN IP addresses
  }
  deriving stock (Show)

-- | Certificate subject information for self-signed certificates
data CertSubject = CertSubject
  { certCountry :: Text
  , certState :: Text
  , certLocality :: Text
  , certOrganization :: Text
  , certOrganizationalUnit :: Text
  , certCommonName :: Text
  }
  deriving stock (Show)

-- | Default certificate subject for development
defaultCertSubject :: CertSubject
defaultCertSubject =
  CertSubject
    { certCountry = "US"
    , certState = "CA"
    , certLocality = "San Francisco"
    , certOrganization = "Vira Development"
    , certOrganizationalUnit = "IT Department"
    , certCommonName = "localhost"
    }

-- | Default certificate request for local development
defaultCertRequest :: Text -> CertificateRequest
defaultCertRequest hostArg =
  CertificateRequest
    { certSubject = defaultCertSubject
    , certValidityDays = 3650 -- 10 years
    , certSANHosts = ["localhost", hostArg]
    , certSANIPs =
        [ "127.0.0.1"
        , "::1"
        , "0.0.0.0"
        , "192.168.1.1"
        , "192.168.1.100"
        , "192.168.0.1"
        , "192.168.0.100"
        , "10.0.0.1"
        , "10.0.0.100"
        , "172.16.0.1"
        , "172.16.0.100"
        ]
    }

-- | Generate a self-signed certificate using a certificate request
generateCertificateWithRequest :: FilePath -> CertificateRequest -> IO ()
generateCertificateWithRequest certDir request = do
  let (certPath, keyPath) = certPaths certDir

  -- Generate private key
  callProcess opensslBin ["genrsa", "-out", keyPath, "2048"]

  -- Create OpenSSL config
  let opensslConfig = generateOpenSSLConfig request
      configPath = certDir <> "/openssl.conf"

  writeFileText configPath opensslConfig

  -- Generate self-signed certificate
  callProcess
    opensslBin
    [ "req"
    , "-new"
    , "-x509"
    , "-key"
    , keyPath
    , "-out"
    , certPath
    , "-days"
    , Prelude.show request.certValidityDays
    , "-config"
    , configPath
    ]

  putTextLn "Generated TLS certificates:"
  putTextLn $ "  Certificate: " <> toText certPath
  putTextLn $ "  Private key: " <> toText keyPath
  let hostList = intercalate ", " (map toString request.certSANHosts)
  putTextLn $ "  Valid for: " <> toText hostList <> " and common local network IPs"

-- | Generate OpenSSL configuration from a certificate request
generateOpenSSLConfig :: CertificateRequest -> Text
generateOpenSSLConfig request =
  let subject = request.certSubject
      dnsEntries =
        zipWith
          (\i host -> "DNS." <> Prelude.show i <> " = " <> host)
          [(1 :: Int) ..]
          request.certSANHosts
      ipEntries =
        zipWith
          (\i ip -> "IP." <> Prelude.show i <> " = " <> ip)
          [(length request.certSANHosts + 1 :: Int) ..]
          request.certSANIPs
      allSANEntries = dnsEntries <> ipEntries
      altNamesSection = unlines $ "[alt_names]" : allSANEntries
   in unlines
        [ "[req]"
        , "distinguished_name = req_distinguished_name"
        , "req_extensions = v3_req"
        , "prompt = no"
        , ""
        , "[req_distinguished_name]"
        , "C = " <> subject.certCountry
        , "ST = " <> subject.certState
        , "L = " <> subject.certLocality
        , "O = " <> subject.certOrganization
        , "OU = " <> subject.certOrganizationalUnit
        , "CN = " <> subject.certCommonName
        , ""
        , "[v3_req]"
        , "basicConstraints = CA:FALSE"
        , "keyUsage = critical, digitalSignature, keyEncipherment, keyAgreement"
        , "extendedKeyUsage = critical, serverAuth, clientAuth"
        , "subjectAltName = @alt_names"
        , ""
        , altNamesSection
        ]

-- | Generate self-signed certificates with proper SAN for local network access (simplified API)
generateCertificates :: FilePath -> Text -> IO ()
generateCertificates certDir hostArg =
  generateCertificateWithRequest certDir (defaultCertRequest hostArg)

{- | Start a Warp server with TLS support

High-level function that combines Warp settings, TLS configuration, and application:

* Resolves the TLS configuration using the state directory
* Starts HTTP server if TLS is disabled
* Starts HTTPS server if TLS is enabled

This is the main entry point for applications using this module.
-}
startWarpServer :: Warp.Settings -> FilePath -> TLSConfig -> Application -> IO ()
startWarpServer settings stateDir tlsConfig app =
  tlsConfigResolve stateDir tlsConfig >>= \case
    Nothing -> Warp.runSettings settings app
    Just tlsSettings -> WarpTLS.runTLS tlsSettings settings app