summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Cady <d@jerkface.net>2017-07-05 10:12:04 -0400
committerAndrew Cady <d@jerkface.net>2017-07-05 10:12:04 -0400
commit4f4cf411f880c1344586690b2621bcab35970673 (patch)
tree3ff24dcb7d82592c6473581a89731efbeb59a880
parent9593b69aeaabe232b828d1983d0728ca13f8e026 (diff)
add command to check for certificate expiration on remote HTTP hosts
-rw-r--r--acme-certify.hs167
1 files changed, 126 insertions, 41 deletions
diff --git a/acme-certify.hs b/acme-certify.hs
index 989940b..735cd04 100644
--- a/acme-certify.hs
+++ b/acme-certify.hs
@@ -1,15 +1,17 @@
1{-# OPTIONS_GHC -fno-warn-name-shadowing #-}
1{-# OPTIONS_GHC -fno-warn-orphans #-} 2{-# OPTIONS_GHC -fno-warn-orphans #-}
2{-# LANGUAGE FlexibleContexts #-} 3{-# LANGUAGE DuplicateRecordFields #-}
3{-# LANGUAGE FlexibleInstances #-} 4{-# LANGUAGE FlexibleContexts #-}
4{-# LANGUAGE MultiWayIf #-} 5{-# LANGUAGE FlexibleInstances #-}
5{-# LANGUAGE NamedFieldPuns #-} 6{-# LANGUAGE MultiWayIf #-}
6{-# LANGUAGE NoImplicitPrelude #-} 7{-# LANGUAGE NamedFieldPuns #-}
7{-# LANGUAGE OverloadedStrings #-} 8{-# LANGUAGE NoImplicitPrelude #-}
8{-# LANGUAGE PackageImports #-} 9{-# LANGUAGE OverloadedStrings #-}
9{-# LANGUAGE RecordWildCards #-} 10{-# LANGUAGE PackageImports #-}
10{-# LANGUAGE ScopedTypeVariables #-} 11{-# LANGUAGE RecordWildCards #-}
11{-# LANGUAGE TypeSynonymInstances #-} 12{-# LANGUAGE ScopedTypeVariables #-}
12{-# LANGUAGE ViewPatterns #-} 13{-# LANGUAGE TypeSynonymInstances #-}
14{-# LANGUAGE ViewPatterns #-}
13 15
14-------------------------------------------------------------------------------- 16--------------------------------------------------------------------------------
15-- | Get a certificate from Let's Encrypt using the ACME protocol. 17-- | Get a certificate from Let's Encrypt using the ACME protocol.
@@ -56,6 +58,7 @@ import qualified Data.ByteString as B
56import Data.PEM (pemContent, pemParseBS) 58import Data.PEM (pemContent, pemParseBS)
57import qualified Data.X509 as X509 59import qualified Data.X509 as X509
58 60
61
59defaultUpdateConfigFile :: FilePath 62defaultUpdateConfigFile :: FilePath
60defaultUpdateConfigFile = "config.yaml" 63defaultUpdateConfigFile = "config.yaml"
61 64
@@ -70,29 +73,39 @@ main = customExecParser (prefs showHelpOnEmpty) (info opts desc) >>= run
70 opts :: Parser Options 73 opts :: Parser Options
71 opts = Options <$> parseCommand 74 opts = Options <$> parseCommand
72 parseCommand :: Parser Command 75 parseCommand :: Parser Command
73 parseCommand = subparser $ 76 parseCommand =
74 command "certify" (info (helper <*> certifyOpts) certifyDesc) <> 77 subparser $
75 command "update" (info (helper <*> updateOpts) updateDesc) 78 command "certify" (info' certifyOpts certifyDesc) <>
76 79 command "update" (info' updateOpts updateDesc) <>
77 desc = fullDesc <> progDesc detailedDescription <> Opt.header "Let's Encrypt! ACME client" 80 command "remote-check" (info' checkOpts checkDesc)
78 detailedDescription = unwords 81 info' o d = info (helper <*> o) (progDesc $ unwords d)
79 [ "This program generates signed TLS certificates" 82 desc =
80 , "using the ACME protocol and the free Let's Encrypt! CA." 83 fullDesc <> progDesc detailedDescription <>
81 ] 84 Opt.header "Let's Encrypt! ACME client"
82 85 detailedDescription =
83 certifyDesc = progDesc $ unwords 86 unwords
84 [ "Generate a single signed TLS certificate" 87 [ "This program generates signed TLS certificates"
85 , "for one or more domains." 88 , "using the ACME protocol and the free Let's Encrypt! CA."
86 ] 89 ]
87 updateDesc = progDesc $ unwords 90 certifyDesc =
88 [ "Generate any number of signed TLS certificates," 91 ["Generate a single signed TLS certificate", "for one or more domains."]
89 , "each certifying any number of domains." 92 updateDesc =
90 ] 93 [ "Generate any number of signed TLS certificates,"
94 , "each certifying any number of domains."
95 ]
96 checkDesc = ["Check certificate expiration on remote HTTPS servers"]
97
91run :: Options -> IO () 98run :: Options -> IO ()
92run (Options (Certify opts)) = runCertify opts >>= either (error . ("Error: " ++)) return 99run (Options (Certify opts)) = runCertify opts >>= either (error . ("Error: " ++)) return
93run (Options (Update opts)) = runUpdate opts 100run (Options (Update opts)) = runUpdate opts
101run (Options (Check opts)) = runCheck opts
102
103data Command = Certify CertifyOpts | Update UpdateOpts | Check CheckOpts
94 104
95data Command = Certify CertifyOpts | Update UpdateOpts 105data CheckOpts = CheckOpts {
106 optDomains :: [String],
107 optConfigFile :: Maybe FilePath
108}
96 109
97data Options = Options { 110data Options = Options {
98 optCommand :: Command 111 optCommand :: Command
@@ -119,6 +132,19 @@ data UpdateOpts = UpdateOpts {
119 updateTryVHosts :: [String] 132 updateTryVHosts :: [String]
120} 133}
121 134
135checkOpts :: Parser Command
136checkOpts =
137 fmap Check $
138 CheckOpts <$>
139 many
140 (argument str $
141 metavar "DOMAINS" <>
142 help "Domains to check (default: from configuration file)") <*>
143 optional
144 (strOption $
145 long "config" <> metavar "FILENAME" <>
146 help "Alternative location of YAML configuration file")
147
122updateOpts :: Parser Command 148updateOpts :: Parser Command
123updateOpts = fmap Update $ 149updateOpts = fmap Update $
124 UpdateOpts <$> optional 150 UpdateOpts <$> optional
@@ -215,7 +241,7 @@ certAltNames sc = toListOf (_Just . _Right . to strip1 . folded) altNames & mapM
215 altNames = X509.extensionGetE $ X509.certExtensions $ X509.signedObject $ X509.getSigned sc 241 altNames = X509.extensionGetE $ X509.certExtensions $ X509.signedObject $ X509.getSigned sc
216 strip1 (X509.ExtSubjectAltName x) = x 242 strip1 (X509.ExtSubjectAltName x) = x
217 strip2 (X509.AltNameDNS x) = Just x 243 strip2 (X509.AltNameDNS x) = Just x
218 strip2 _ = Nothing 244 strip2 _ = Nothing
219 245
220data NeedCertReason = Expired | NearExpiration | SubDomainsAdded | NoExistingCert | InvalidExistingCert deriving Show 246data NeedCertReason = Expired | NearExpiration | SubDomainsAdded | NoExistingCert | InvalidExistingCert deriving Show
221needToFetch :: CertSpec -> IO (Either NeedCertReason ()) 247needToFetch :: CertSpec -> IO (Either NeedCertReason ())
@@ -225,14 +251,20 @@ needToFetch cs@CertSpec{..} = runExceptT $ do
225 251
226 -- TODO: parse with cryptonite 252 -- TODO: parse with cryptonite
227 cert <- liftIO $ readFile certFile >>= readX509 253 cert <- liftIO $ readFile certFile >>= readX509
228 expiration <- liftIO $ getNotAfter cert 254 checkCertExpiration cert
229 now <- liftIO getCurrentTime
230 255
231 signedCert <- (liftIO (readSignedObject certFile) >>=) $ 256 signedCert <- (liftIO (readSignedObject certFile) >>=) $
232 maybe (throwError InvalidExistingCert) return . preview (folded . _Right) 257 maybe (throwError InvalidExistingCert) return . preview (folded . _Right)
233 let wantedDomains = domainToString . fst <$> csDomains 258 let wantedDomains = domainToString . fst <$> csDomains
234 haveDomains = certAltNames signedCert 259 haveDomains = certAltNames signedCert
235 unless (null $ wantedDomains \\ haveDomains) $ throwError SubDomainsAdded 260 unless (null $ wantedDomains \\ haveDomains) $ throwError SubDomainsAdded
261 where
262 certFile = domainCertFile cs
263
264checkCertExpiration :: X509 -> ExceptT NeedCertReason IO ()
265checkCertExpiration cert = do
266 now <- liftIO getCurrentTime
267 expiration <- liftIO $ getNotAfter cert
236 268
237 if | expiration < now -> throwError Expired 269 if | expiration < now -> throwError Expired
238 | expiration < addUTCTime graceTime now -> throwError NearExpiration 270 | expiration < addUTCTime graceTime now -> throwError NearExpiration
@@ -240,7 +272,6 @@ needToFetch cs@CertSpec{..} = runExceptT $ do
240 where 272 where
241 graceTime = days 20 273 graceTime = days 20
242 days = (*) (24 * 60 * 60) 274 days = (*) (24 * 60 * 60)
243 certFile = domainCertFile cs
244 275
245readSignedObject :: (Eq a, Show a, Data.ASN1.Types.ASN1Object a) => FilePath -> IO [Either String (X509.SignedExact a)] 276readSignedObject :: (Eq a, Show a, Data.ASN1.Types.ASN1Object a) => FilePath -> IO [Either String (X509.SignedExact a)]
246readSignedObject = 277readSignedObject =
@@ -248,12 +279,18 @@ readSignedObject =
248 either error (map (X509.decodeSignedObject . pemContent)) . pemParseBS 279 either error (map (X509.decodeSignedObject . pemContent)) . pemParseBS
249 280
250configGetCertReqs :: Config -> IO [(String, DomainName, [VHostSpec])] 281configGetCertReqs :: Config -> IO [(String, DomainName, [VHostSpec])]
251configGetCertReqs hostsConfig = do 282configGetCertReqs hostsConfig = fmap concat $ forHosts hostsConfig $ do
252 fmap concat $ forM (Config.keys hostsConfig) $ \host -> 283 \host hostParts -> return $
253 do 284 flip map (HashMap.keys hostParts) $ \domain ->
254 hostParts <- (Config.subconfig host hostsConfig >>= Config.subconfig "domains") <&> extractObject 285 ( unpack host
255 return $ flip map (HashMap.keys hostParts) $ \domain -> 286 , domainName' $ unpack domain
256 (unpack host, domainName' $ unpack domain, combineSubdomains domain hostParts) 287 , combineSubdomains domain hostParts)
288
289forHosts :: Config -> (Config.Key -> Object -> IO a) -> IO [a]
290forHosts hostsConfig f =
291 forM (Config.keys hostsConfig) $ \host -> do
292 hostParts <- (Config.subconfig host hostsConfig >>= Config.subconfig "domains") <&> extractObject
293 f host hostParts
257 294
258combineSubdomains :: AsPrimitive v => Text -> HashMap.HashMap Text v -> [VHostSpec] 295combineSubdomains :: AsPrimitive v => Text -> HashMap.HashMap Text v -> [VHostSpec]
259combineSubdomains domain subs = 296combineSubdomains domain subs =
@@ -264,6 +301,51 @@ combineSubdomains domain subs =
264extractObject :: Config -> Object 301extractObject :: Config -> Object
265extractObject (Config _ o) = o 302extractObject (Config _ o) = o
266 303
304checkRemoteCertExpiry :: DomainName -> IO (Either NeedCertReason ())
305checkRemoteCertExpiry = runExceptT . (getRemoteCert >=> checkCertExpiration)
306 where
307 getRemoteCert :: DomainName -> ExceptT NeedCertReason IO X509
308 getRemoteCert (domainToString -> domain) = do
309 certText <- fetch ""
310 cert <- extract certText
311 liftIO $ readX509 cert
312 where
313 cmd p a = do
314 (e, out, _err) <- liftIO $ readCreateProcessWithExitCode p a
315 when (e /= ExitSuccess) $ throwError NoExistingCert -- TODO
316 return out
317
318 fetch = cmd $ proc "openssl" ["s_client", "-connect", domain ++ ":443", "-servername", domain]
319 extract = cmd $ proc "openssl" ["x509"]
320
321configFileGetCertReqs :: Maybe FilePath -> IO [(String, DomainName, [VHostSpec])]
322configFileGetCertReqs configFile = do
323 config <- Config.load $ fromMaybe defaultUpdateConfigFile configFile
324 Config.subconfig "hosts" config >>= configGetCertReqs
325
326runCheck :: CheckOpts -> IO ()
327runCheck CheckOpts {..} = do
328 domainsToCheck <- if null optDomains
329 then configFileGetCertReqs optConfigFile <&> (extractSubdomains `concatMap`)
330 else return $ domainName' <$> optDomains
331
332 checkedDomains <- map plumb <$> forM domainsToCheck (bothA return checkRemoteCertExpiry)
333 let verified = rights checkedDomains
334 when (not $ null verified) $
335 putStrLn $ ("Verified: " ++) $ unwords $ domainToString <$> verified
336
337 mapM_ print $ lefts checkedDomains
338
339 return ()
340 where
341 extractSubdomains :: (String, DomainName, [VHostSpec]) -> [DomainName]
342 extractSubdomains (_,_,a) = vhsDomain <$> a
343 bothA f g a = (,) <$> f a <*> g a
344
345 plumb :: (a, Either b ()) -> Either (a, b) a
346 plumb (d, Right ()) = Right d
347 plumb (d, Left r) = Left (d, r)
348
267runUpdate :: UpdateOpts -> IO () 349runUpdate :: UpdateOpts -> IO ()
268runUpdate UpdateOpts { .. } = do 350runUpdate UpdateOpts { .. } = do
269 issuerCert <- readX509 letsEncryptX3CrossSigned 351 issuerCert <- readX509 letsEncryptX3CrossSigned
@@ -337,7 +419,10 @@ runUpdate UpdateOpts { .. } = do
337domainToString :: DomainName -> String 419domainToString :: DomainName -> String
338domainToString = unpack . decodeUtf8 . Text.Domain.Validate.toByteString 420domainToString = unpack . decodeUtf8 . Text.Domain.Validate.toByteString
339 421
340data VHostSpec = VHostSpec DomainName (Either DomainName FilePath) deriving Show 422data VHostSpec = VHostSpec
423 { vhsDomain :: DomainName
424 , vhsProvisionInfo :: (Either DomainName FilePath)
425 } deriving (Show)
341makeVHostSpec :: DomainName -> String -> VHostSpec 426makeVHostSpec :: DomainName -> String -> VHostSpec
342makeVHostSpec parentDomain vhostSpecStr = VHostSpec (domainName' vhostName) (left domainName' spec) 427makeVHostSpec parentDomain vhostSpecStr = VHostSpec (domainName' vhostName) (left domainName' spec)
343 where 428 where