diff options
author | Andrew Cady <d@jerkface.net> | 2017-07-05 10:12:04 -0400 |
---|---|---|
committer | Andrew Cady <d@jerkface.net> | 2017-07-05 10:12:04 -0400 |
commit | 4f4cf411f880c1344586690b2621bcab35970673 (patch) | |
tree | 3ff24dcb7d82592c6473581a89731efbeb59a880 | |
parent | 9593b69aeaabe232b828d1983d0728ca13f8e026 (diff) |
add command to check for certificate expiration on remote HTTP hosts
-rw-r--r-- | acme-certify.hs | 167 |
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 | |||
56 | import Data.PEM (pemContent, pemParseBS) | 58 | import Data.PEM (pemContent, pemParseBS) |
57 | import qualified Data.X509 as X509 | 59 | import qualified Data.X509 as X509 |
58 | 60 | ||
61 | |||
59 | defaultUpdateConfigFile :: FilePath | 62 | defaultUpdateConfigFile :: FilePath |
60 | defaultUpdateConfigFile = "config.yaml" | 63 | defaultUpdateConfigFile = "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 | |||
91 | run :: Options -> IO () | 98 | run :: Options -> IO () |
92 | run (Options (Certify opts)) = runCertify opts >>= either (error . ("Error: " ++)) return | 99 | run (Options (Certify opts)) = runCertify opts >>= either (error . ("Error: " ++)) return |
93 | run (Options (Update opts)) = runUpdate opts | 100 | run (Options (Update opts)) = runUpdate opts |
101 | run (Options (Check opts)) = runCheck opts | ||
102 | |||
103 | data Command = Certify CertifyOpts | Update UpdateOpts | Check CheckOpts | ||
94 | 104 | ||
95 | data Command = Certify CertifyOpts | Update UpdateOpts | 105 | data CheckOpts = CheckOpts { |
106 | optDomains :: [String], | ||
107 | optConfigFile :: Maybe FilePath | ||
108 | } | ||
96 | 109 | ||
97 | data Options = Options { | 110 | data 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 | ||
135 | checkOpts :: Parser Command | ||
136 | checkOpts = | ||
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 | |||
122 | updateOpts :: Parser Command | 148 | updateOpts :: Parser Command |
123 | updateOpts = fmap Update $ | 149 | updateOpts = 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 | ||
220 | data NeedCertReason = Expired | NearExpiration | SubDomainsAdded | NoExistingCert | InvalidExistingCert deriving Show | 246 | data NeedCertReason = Expired | NearExpiration | SubDomainsAdded | NoExistingCert | InvalidExistingCert deriving Show |
221 | needToFetch :: CertSpec -> IO (Either NeedCertReason ()) | 247 | needToFetch :: 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 | |||
264 | checkCertExpiration :: X509 -> ExceptT NeedCertReason IO () | ||
265 | checkCertExpiration 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 | ||
245 | readSignedObject :: (Eq a, Show a, Data.ASN1.Types.ASN1Object a) => FilePath -> IO [Either String (X509.SignedExact a)] | 276 | readSignedObject :: (Eq a, Show a, Data.ASN1.Types.ASN1Object a) => FilePath -> IO [Either String (X509.SignedExact a)] |
246 | readSignedObject = | 277 | readSignedObject = |
@@ -248,12 +279,18 @@ readSignedObject = | |||
248 | either error (map (X509.decodeSignedObject . pemContent)) . pemParseBS | 279 | either error (map (X509.decodeSignedObject . pemContent)) . pemParseBS |
249 | 280 | ||
250 | configGetCertReqs :: Config -> IO [(String, DomainName, [VHostSpec])] | 281 | configGetCertReqs :: Config -> IO [(String, DomainName, [VHostSpec])] |
251 | configGetCertReqs hostsConfig = do | 282 | configGetCertReqs 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 | |||
289 | forHosts :: Config -> (Config.Key -> Object -> IO a) -> IO [a] | ||
290 | forHosts 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 | ||
258 | combineSubdomains :: AsPrimitive v => Text -> HashMap.HashMap Text v -> [VHostSpec] | 295 | combineSubdomains :: AsPrimitive v => Text -> HashMap.HashMap Text v -> [VHostSpec] |
259 | combineSubdomains domain subs = | 296 | combineSubdomains domain subs = |
@@ -264,6 +301,51 @@ combineSubdomains domain subs = | |||
264 | extractObject :: Config -> Object | 301 | extractObject :: Config -> Object |
265 | extractObject (Config _ o) = o | 302 | extractObject (Config _ o) = o |
266 | 303 | ||
304 | checkRemoteCertExpiry :: DomainName -> IO (Either NeedCertReason ()) | ||
305 | checkRemoteCertExpiry = 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 | |||
321 | configFileGetCertReqs :: Maybe FilePath -> IO [(String, DomainName, [VHostSpec])] | ||
322 | configFileGetCertReqs configFile = do | ||
323 | config <- Config.load $ fromMaybe defaultUpdateConfigFile configFile | ||
324 | Config.subconfig "hosts" config >>= configGetCertReqs | ||
325 | |||
326 | runCheck :: CheckOpts -> IO () | ||
327 | runCheck 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 | |||
267 | runUpdate :: UpdateOpts -> IO () | 349 | runUpdate :: UpdateOpts -> IO () |
268 | runUpdate UpdateOpts { .. } = do | 350 | runUpdate UpdateOpts { .. } = do |
269 | issuerCert <- readX509 letsEncryptX3CrossSigned | 351 | issuerCert <- readX509 letsEncryptX3CrossSigned |
@@ -337,7 +419,10 @@ runUpdate UpdateOpts { .. } = do | |||
337 | domainToString :: DomainName -> String | 419 | domainToString :: DomainName -> String |
338 | domainToString = unpack . decodeUtf8 . Text.Domain.Validate.toByteString | 420 | domainToString = unpack . decodeUtf8 . Text.Domain.Validate.toByteString |
339 | 421 | ||
340 | data VHostSpec = VHostSpec DomainName (Either DomainName FilePath) deriving Show | 422 | data VHostSpec = VHostSpec |
423 | { vhsDomain :: DomainName | ||
424 | , vhsProvisionInfo :: (Either DomainName FilePath) | ||
425 | } deriving (Show) | ||
341 | makeVHostSpec :: DomainName -> String -> VHostSpec | 426 | makeVHostSpec :: DomainName -> String -> VHostSpec |
342 | makeVHostSpec parentDomain vhostSpecStr = VHostSpec (domainName' vhostName) (left domainName' spec) | 427 | makeVHostSpec parentDomain vhostSpecStr = VHostSpec (domainName' vhostName) (left domainName' spec) |
343 | where | 428 | where |