packages feed

hprox-0.7.1: test/Network/HProx/TLSSpec.hs

-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.

module Network.HProx.TLSSpec
  ( spec
  ) where

import Control.Exception
    (ErrorCall, SomeException, bracket, displayException, fromException, try)
import Crypto.Hash           qualified as Hash
import Data.ByteString       qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.IORef            (modifyIORef', newIORef, readIORef)
import Data.List             (isInfixOf)
import Data.Maybe            (isNothing)
import Network.HProx.Config  (CertFile(..))
import Network.HProx.Log     (LogLevel(..), Logger)
import Network.HProx.Runtime
import Network.TLS           qualified as TLS

import System.Directory      (getTemporaryDirectory, removeFile)
import System.IO             (hClose, openTempFile)
import System.Log.FastLogger (fromLogStr)
import Test.Hspec

spec :: Spec
spec = do
  describe "SNI selection" $ do
    it "matches exact SNI hostnames case-insensitively" $ do
      lookupSNIHost (Just "EXAMPLE.com") [("example.com", "cert" :: String), ("other.example", "other")]
        `shouldBe` Right "cert"
      sniPatternMatches "EXAMPLE.com" "example.COM" `shouldBe` True
      sniPatternMatches "Ä.example.com" "ä.example.com" `shouldBe` False

    it "matches wildcard SNI hostnames case-insensitively for one label only" $ do
      sniPatternMatches "api.example.com" "*.example.com" `shouldBe` True
      sniPatternMatches "API.example.com" "*.EXAMPLE.com" `shouldBe` True
      sniPatternMatches "deep.api.example.com" "*.example.com" `shouldBe` False
      sniPatternMatches "example.com" "*.example.com" `shouldBe` False
      sniPatternMatches ".example.com" "*.example.com" `shouldBe` False
      sniPatternMatches "badexample.com" "*.example.com" `shouldBe` False

    it "returns the first matching configured SNI certificate" $
      lookupSNIHost (Just "api.example.com")
        [ ("*.example.com", "wildcard" :: String)
        , ("api.example.com", "exact")
        ] `shouldBe` Right "wildcard"

    it "rejects multi-label wildcard SNI matches during lookup" $
      lookupSNIHost (Just "deep.api.example.com") [("*.example.com", "wildcard" :: String)]
        `shouldBe` Left "SNI: unknown hostname (\"deep.api.example.com\")"

    it "rejects unknown and missing SNI hosts with current failure messages" $ do
      lookupSNIHost (Just "unknown.example") [("example.com", "cert" :: String)]
        `shouldBe` Left "SNI: unknown hostname (\"unknown.example\")"
      lookupSNIHost Nothing [("example.com", "cert" :: String)]
        `shouldBe` Left "SNI: unspecified"

  describe "TLS credential loading" $ do
    it "throws a contextual IO exception when certificate loading fails" $ do
      certPath <- missingPath "hprox-missing-cert.pem"
      keyPath <- missingPath "hprox-missing-key.pem"
      result <- try (loadTlsCredentials [("example.com", CertFile certPath keyPath)]) :: IO (Either SomeException [(String, TLS.Credential)])
      case result of
        Left ex -> do
          fromException ex `shouldSatisfy` (isNothing :: Maybe ErrorCall -> Bool)
          let message = displayException ex
          message `shouldContain` "example.com"
          message `shouldContain` certPath
          message `shouldContain` keyPath
        Right _ -> expectationFailure "expected TLS credential loading to fail"

    it "rejects production credentials with an empty certificate chain" $
      loadTlsCredentialFromMemory "not a certificate" testPrivateKey
        `shouldBe` Left "loaded TLS credential has an empty certificate chain"

    it "keeps the old production credential when reload parses an empty certificate chain" $
      withTempCredential "hprox-valid" testCertificate testPrivateKey $ \certFile -> do
        store <- loadTlsCredentialStore [("example.com", certFile)]
        oldSnapshot <- readTlsCredentialStore store
        writeCredentialFiles certFile "not a certificate" testPrivateKey
        (logger, readLogs) <- captureLogger
        reloadTlsCredentialStore logger store
        readTlsCredentialStore store `shouldReturn` oldSnapshot
        logs <- readLogs
        let errorMessages = [message | (ERROR, message) <- logs]
        length errorMessages `shouldBe` 1
        unlines errorMessages `shouldContain` "example.com"
        unlines errorMessages `shouldContain` certfile certFile
        unlines errorMessages `shouldContain` keyfile certFile
        unlines errorMessages `shouldContain` "empty certificate chain"

  describe "reloadable TLS credential store" $ do
    it "looks up SNI credentials from the current store snapshot" $
      withTempCredential "hprox-store" "cert-a" "key-a" $ \certFile -> do
        store <- newTlsCredentialStore testCredentialLoader [("example.com", certFile)]
        lookupTlsCredentialStore (Just "example.com") store `shouldReturn` "cert-a:key-a"
        writeCredentialFiles certFile "cert-b" "key-b"
        (logger, _) <- captureLogger
        reloadTlsCredentialStore logger store
        lookupTlsCredentialStore (Just "example.com") store `shouldReturn` "cert-b:key-b"

    it "selects the current first configured credential for missing SNI" $
      withTempCredential "hprox-first" "first-cert" "first-key" $ \firstCert ->
      withTempCredential "hprox-second" "second-cert" "second-key" $ \secondCert -> do
        store <- newTlsCredentialStore testCredentialLoader
          [ ("first.example", firstCert)
          , ("second.example", secondCert)
          ]
        lookupTlsCredentialStore Nothing store `shouldReturn` "first-cert:first-key"
        writeCredentialFiles firstCert "new-first-cert" "new-first-key"
        (logger, _) <- captureLogger
        reloadTlsCredentialStore logger store
        lookupTlsCredentialStore Nothing store `shouldReturn` "new-first-cert:new-first-key"

    it "logs an install message only for changed credentials" $
      withTempCredential "hprox-first" "first-cert" "first-key" $ \firstCert ->
      withTempCredential "hprox-second" "second-cert" "second-key" $ \secondCert -> do
        store <- newTlsCredentialStore testCredentialLoader
          [ ("first.example", firstCert)
          , ("second.example", secondCert)
          ]
        writeCredentialFiles firstCert "new-first-cert" "new-first-key"
        (logger, readLogs) <- captureLogger
        reloadTlsCredentialStore logger store
        readTlsCredentialStore store `shouldReturn`
          [ ("first.example", "new-first-cert:new-first-key")
          , ("second.example", "second-cert:second-key")
          ]
        logs <- readLogs
        let installMessages = [message | (INFO, message) <- logs, "installed reloaded TLS credential" `isInfixOf` message]
        length installMessages `shouldBe` 1
        unlines installMessages `shouldContain` "first.example"
        unlines installMessages `shouldContain` certfile firstCert
        unlines installMessages `shouldNotSatisfy` isInfixOf (certfile secondCert)

    it "keeps a failed entry, updates successful entries, and logs failure context" $
      withTempCredential "hprox-first" "first-cert" "first-key" $ \firstCert ->
      withTempCredential "hprox-second" "second-cert" "second-key" $ \secondCert -> do
        store <- newTlsCredentialStore failingCredentialLoader
          [ ("first.example", firstCert)
          , ("second.example", secondCert)
          ]
        writeCredentialFiles firstCert "bad-cert" "first-key"
        writeCredentialFiles secondCert "new-second-cert" "new-second-key"
        (logger, readLogs) <- captureLogger
        reloadTlsCredentialStore logger store
        readTlsCredentialStore store `shouldReturn`
          [ ("first.example", "first-cert:first-key")
          , ("second.example", "new-second-cert:new-second-key")
          ]
        logs <- readLogs
        let errorMessages = [message | (ERROR, message) <- logs]
        length errorMessages `shouldBe` 1
        unlines errorMessages `shouldContain` "first.example"
        unlines errorMessages `shouldContain` certfile firstCert
        unlines errorMessages `shouldContain` keyfile firstCert
        unlines errorMessages `shouldContain` "bad test credential"


missingPath :: String -> IO FilePath
missingPath template = do
  tmpDir <- getTemporaryDirectory
  (path, handle) <- openTempFile tmpDir template
  hClose handle
  removeFile path
  return path

type CapturedLogs = [(LogLevel, String)]

captureLogger :: IO (Logger, IO CapturedLogs)
captureLogger = do
  ref <- newIORef []
  let logger level message =
        modifyIORef' ref (++ [(level, BS8.unpack $ fromLogStr message)])
  return (logger, readIORef ref)

testCredentialLoader :: TlsCredentialLoader String
testCredentialLoader certFile = do
  certBytes <- BS.readFile $ certfile certFile
  keyBytes <- BS.readFile $ keyfile certFile
  return $ Right LoadedCredential
    { loadedCredentialValue = credentialValue certBytes keyBytes
    , loadedCredentialCertificateFingerprint = fingerprintBytes certBytes
    , loadedCredentialKeyFingerprint = fingerprintBytes keyBytes
    }

failingCredentialLoader :: TlsCredentialLoader String
failingCredentialLoader certFile = do
  certBytes <- BS.readFile $ certfile certFile
  keyBytes <- BS.readFile $ keyfile certFile
  if certBytes == "bad-cert"
    then return $ Left "bad test credential"
    else return $ Right LoadedCredential
      { loadedCredentialValue = credentialValue certBytes keyBytes
      , loadedCredentialCertificateFingerprint = fingerprintBytes certBytes
      , loadedCredentialKeyFingerprint = fingerprintBytes keyBytes
      }

credentialValue :: BS.ByteString -> BS.ByteString -> String
credentialValue certBytes keyBytes =
  BS8.unpack certBytes <> ":" <> BS8.unpack keyBytes

fingerprintBytes :: BS.ByteString -> Hash.Digest Hash.SHA256
fingerprintBytes = Hash.hash

withTempCredential :: String -> BS.ByteString -> BS.ByteString -> (CertFile -> IO a) -> IO a
withTempCredential template certBytes keyBytes =
  bracket acquire release
  where
    acquire = do
      tmpDir <- getTemporaryDirectory
      (certPath, certHandle) <- openTempFile tmpDir $ template <> "-cert.pem"
      (keyPath, keyHandle) <- openTempFile tmpDir $ template <> "-key.pem"
      hClose certHandle
      hClose keyHandle
      let certFile = CertFile certPath keyPath
      writeCredentialFiles certFile certBytes keyBytes
      return certFile

    release (CertFile certPath keyPath) = do
      removeFile certPath
      removeFile keyPath

writeCredentialFiles :: CertFile -> BS.ByteString -> BS.ByteString -> IO ()
writeCredentialFiles (CertFile certPath keyPath) certBytes keyBytes = do
  BS.writeFile certPath certBytes
  BS.writeFile keyPath keyBytes

testPrivateKey :: BS.ByteString
testPrivateKey = BS8.pack $
  "-----BEGIN PRIVATE KEY-----\n" <>
  "MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAOWBh4304R0FzpHZ\n" <>
  "PltVBN0lZ3WRqF/ETDhtqR071G/d7lkikLu0QXP7pCaDK4rHcHSm3TmE1Flcyc94\n" <>
  "wfFIVJinXEiJd+bp1Yk/ZWkWTYMEX5OuYzWBhciULt+ekMz8I2W+gtFUyu5rL9M9\n" <>
  "DaNPwQijTxYaykBJ4uXzUjxm7NwhAgMBAAECgYB6ndeUak6TOPUCSzTbivLMTB2Y\n" <>
  "XLe+YpvuUfhWXA7FraaYDLWS81084Cb1RINQ4/ka+cOb5XGmRMK1i+jiRiibWw38\n" <>
  "R/j0xZhsHSaGSuFlP189O+eA00q34vw0cvKxl1OJGcRF8/lq4qrpgh63iAM+EAC/\n" <>
  "9ncMbpkQrmfluuuOAQJBAP6TLeVAnKnj4i0MtuN1xhofZ3Kdr2Ds1cbHxpeT/yE1\n" <>
  "Q8HZEIynoFVfTZe2fJs0V6YlLlv4lJhfIXy/EixrmvECQQDmymzKz5hJhxlWNLLy\n" <>
  "3t92gh3o03u5ijM7c+HVLJZVI4Co2ai9f0aRifOWKRAbmth5zUVDcJG53jMaQeQQ\n" <>
  "D3QxAkEAyR0AvwHCQjyza5+FxEBAllaE5PlJmarAX99nNkxG27c2pieTeWrbsVYu\n" <>
  "+FHEMuCw9aKd8y54Rb+xttlDxC/mIQJBAMLuUrlyYhQokdPoKwVMDb6Q5CZVCfmK\n" <>
  "qv8aP7LIOCmtFOyI+ycjKz2eISnBgSNvxEwMfuYZXFx7OvqAkNqn0uECQQC59Uk/\n" <>
  "yEl0AkTKP2tcn55afpArQfPdZbIns6koiAMpoyxJPTsB2wL9b77Z8cNfgknFQVpP\n" <>
  "vpX74UfeEF0AYB7t\n" <>
  "-----END PRIVATE KEY-----\n"

testCertificate :: BS.ByteString
testCertificate = BS8.pack $
  "-----BEGIN CERTIFICATE-----\n" <>
  "MIICCDCCAXGgAwIBAgIUdG9y2ED6kiMvkMveoyy1uxRoeIUwDQYJKoZIhvcNAQEL\n" <>
  "BQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjYwNTI2MTUzMzQyWhcNMjYw\n" <>
  "NTI3MTUzMzQyWjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTCBnzANBgkqhkiG9w0B\n" <>
  "AQEFAAOBjQAwgYkCgYEA5YGHjfThHQXOkdk+W1UE3SVndZGoX8RMOG2pHTvUb93u\n" <>
  "WSKQu7RBc/ukJoMrisdwdKbdOYTUWVzJz3jB8UhUmKdcSIl35unViT9laRZNgwRf\n" <>
  "k65jNYGFyJQu356QzPwjZb6C0VTK7msv0z0No0/BCKNPFhrKQEni5fNSPGbs3CEC\n" <>
  "AwEAAaNTMFEwHQYDVR0OBBYEFIVxo9w8xVcdjoMDWXAc45756MAsMB8GA1UdIwQY\n" <>
  "MBaAFIVxo9w8xVcdjoMDWXAc45756MAsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI\n" <>
  "hvcNAQELBQADgYEApCff6vbA5sIRVjRuxSj0Lw37RI6Qb8vaXDELxIrmXVL4b3al\n" <>
  "fLV9eJ54Lmv5mws+mziC2Nohvh6nW343oRip85u8OfalLi5enxq7FnWoXeOPVUmr\n" <>
  "8DnHkJjatk0TVsvV6JLHeJDzX/Pqw4G8JrBYbQVgOqsfWw6bk2vkrHzYOgA=\n" <>
  "-----END CERTIFICATE-----\n"