diff options
author | Andrew Cady <d@jerkface.net> | 2016-01-21 00:20:54 -0500 |
---|---|---|
committer | Andrew Cady <d@jerkface.net> | 2016-01-21 00:29:42 -0500 |
commit | c7a9033041a0b6eb4006cb195a84233577f817e3 (patch) | |
tree | fb0fbacb1255cec659fc22c316a839aa0dfd540b | |
parent | e5e066657123c6090ee1e673d87c7c24c2af71b5 (diff) |
It _does_ work!
The fix was: don't repeat the request after seeing "pending." Turns out
the cert was actually being issued.
Besides that, a "--staging" option was added to allow testing against
Let's Encrypt staging servers. This is necessary for success because I
am now rate-limited!
Error reporting is improved and code is cleaned up somewhat.
-rw-r--r-- | acme.hs | 121 |
1 files changed, 58 insertions, 63 deletions
@@ -11,7 +11,6 @@ | |||
11 | 11 | ||
12 | module Main where | 12 | module Main where |
13 | 13 | ||
14 | import Control.Concurrent (threadDelay) | ||
15 | import Control.Lens hiding ((.=)) | 14 | import Control.Lens hiding ((.=)) |
16 | import Control.Monad | 15 | import Control.Monad |
17 | import Control.Monad.RWS.Strict | 16 | import Control.Monad.RWS.Strict |
@@ -45,11 +44,11 @@ import OpenSSL.RSA | |||
45 | import Options.Applicative hiding (header) | 44 | import Options.Applicative hiding (header) |
46 | import qualified Options.Applicative as Opt | 45 | import qualified Options.Applicative as Opt |
47 | import System.Directory | 46 | import System.Directory |
48 | import System.Exit | ||
49 | import System.Process (readProcess) | 47 | import System.Process (readProcess) |
50 | 48 | ||
51 | directoryUrl :: String | 49 | stagingDirectoryUrl, liveDirectoryUrl :: String |
52 | directoryUrl = "https://acme-v01.api.letsencrypt.org/directory" | 50 | liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" |
51 | stagingDirectoryUrl = "https://acme-staging.api.letsencrypt.org/directory" | ||
53 | 52 | ||
54 | main :: IO () | 53 | main :: IO () |
55 | main = execParser opts >>= go | 54 | main = execParser opts >>= go |
@@ -62,7 +61,8 @@ data CmdOpts = CmdOpts { | |||
62 | optDomain :: String, | 61 | optDomain :: String, |
63 | optChallengeDir :: String, | 62 | optChallengeDir :: String, |
64 | optEmail :: Maybe String, | 63 | optEmail :: Maybe String, |
65 | optTerms :: Maybe String | 64 | optTerms :: Maybe String, |
65 | optStaging :: Bool | ||
66 | } | 66 | } |
67 | 67 | ||
68 | defaultTerms :: String | 68 | defaultTerms :: String |
@@ -74,6 +74,7 @@ cmdopts = CmdOpts <$> strOption (long "key" <> metavar "FILE" <> help "filename | |||
74 | <*> strOption (long "dir" <> metavar "DIR" <> help "output directory for ACME challenges") | 74 | <*> strOption (long "dir" <> metavar "DIR" <> help "output directory for ACME challenges") |
75 | <*> optional (strOption (long "email" <> metavar "ADDRESS" <> help "an email address with which to register an account")) | 75 | <*> optional (strOption (long "email" <> metavar "ADDRESS" <> help "an email address with which to register an account")) |
76 | <*> optional (strOption (long "terms" <> metavar "URL" <> help "the terms param of the registration request")) | 76 | <*> optional (strOption (long "terms" <> metavar "URL" <> help "the terms param of the registration request")) |
77 | <*> switch (long "staging" <> help "use staging servers instead of live servers (certificates will not be real!)") | ||
77 | 78 | ||
78 | genKey :: String -> IO () | 79 | genKey :: String -> IO () |
79 | genKey privKeyFile = void $ readProcess "openssl" (words "genrsa -out" ++ [privKeyFile, "4096"]) "" | 80 | genKey privKeyFile = void $ readProcess "openssl" (words "genrsa -out" ++ [privKeyFile, "4096"]) "" |
@@ -90,8 +91,14 @@ readKeys privKeyFile = do | |||
90 | pub <- rsaCopyPublic $ fromMaybe (error "Error: failed to parse RSA key.") (toKeyPair priv :: Maybe RSAKeyPair) | 91 | pub <- rsaCopyPublic $ fromMaybe (error "Error: failed to parse RSA key.") (toKeyPair priv :: Maybe RSAKeyPair) |
91 | return $ Keys priv pub | 92 | return $ Keys priv pub |
92 | 93 | ||
94 | data ChallengeRequest = ChallengeRequest { crUri :: String, crToken :: ByteString, crThumbToken :: ByteString } | ||
95 | |||
93 | go :: CmdOpts -> IO () | 96 | go :: CmdOpts -> IO () |
94 | go (CmdOpts privKeyFile domain challengeDir email termOverride) = do | 97 | go (CmdOpts privKeyFile domain challengeDir email termOverride staging) = do |
98 | let terms = fromMaybe defaultTerms termOverride | ||
99 | directoryUrl = if staging | ||
100 | then stagingDirectoryUrl | ||
101 | else liveDirectoryUrl | ||
95 | 102 | ||
96 | doesFileExist privKeyFile >>= flip unless (genKey privKeyFile) | 103 | doesFileExist privKeyFile >>= flip unless (genKey privKeyFile) |
97 | 104 | ||
@@ -104,73 +111,66 @@ go (CmdOpts privKeyFile domain challengeDir email termOverride) = do | |||
104 | doesFileExist domainCSRFile >>= flip unless (genReq domainKeyFile domain domainCSRFile) | 111 | doesFileExist domainCSRFile >>= flip unless (genReq domainKeyFile domain domainCSRFile) |
105 | 112 | ||
106 | csrData <- B.readFile domainCSRFile | 113 | csrData <- B.readFile domainCSRFile |
114 | keys <- readKeys privKeyFile | ||
107 | 115 | ||
108 | keys@(Keys _ pub) <- readKeys privKeyFile | 116 | -- TODO: verify that challengeDir is writable before continuing. |
109 | let terms = fromMaybe defaultTerms termOverride | ||
110 | 117 | ||
111 | -- Create user account | ||
112 | runACME directoryUrl keys $ do | 118 | runACME directoryUrl keys $ do |
113 | forM_ email $ register terms >=> statusReport | 119 | forM_ email $ register terms >=> statusReport |
114 | 120 | ||
115 | r <- challengeRequest domain >>= statusReport | 121 | (ChallengeRequest nextUri token thumbtoken) <- challengeRequest domain >>= statusReport >>= extractCR |
116 | let | ||
117 | |||
118 | httpChallenge :: (Value -> Const (Endo s) Value) -> Response LC.ByteString -> Const (Endo s) (Response LC.ByteString) | ||
119 | httpChallenge = responseBody . JSON.key "challenges" . to universe . traverse . (filtered . has $ ix "type" . only "http-01") | ||
120 | |||
121 | token = r ^?! httpChallenge . JSON.key "token" . _String . to encodeUtf8 | ||
122 | crUri = r ^?! httpChallenge . JSON.key "uri" . _String . to T.unpack | ||
123 | |||
124 | thumb = thumbprint (JWK (rsaE pub) "RSA" (rsaN pub)) | ||
125 | thumbtoken = toStrict (LB.fromChunks [token, ".", thumb]) | ||
126 | |||
127 | liftIO $ do | ||
128 | mapM_ print $ r ^@.. httpChallenge . members | ||
129 | print $ encodeOrdered (JWK (rsaE pub) "RSA" (rsaN pub)) | ||
130 | 122 | ||
131 | -- liftIO $ LC.writeFile (challengeDir </> BC.unpack token) p | ||
132 | liftIO $ BC.writeFile (challengeDir </> BC.unpack token) thumbtoken | 123 | liftIO $ BC.writeFile (challengeDir </> BC.unpack token) thumbtoken |
133 | 124 | ||
134 | -- Wait for challenge validation | ||
135 | -- TODO: first hit the local server to test whether this is valid | 125 | -- TODO: first hit the local server to test whether this is valid |
136 | r <- pollChallenge crUri thumbtoken >>= statusReport | ||
137 | 126 | ||
138 | if r ^. responseStatus . statusCode . to isSuccess then do | 127 | notifyChallenge nextUri thumbtoken >>= statusReport >>= ncErrorReport |
139 | 128 | ||
140 | liftIO $ void exitSuccess | 129 | retrieveCert csrData >>= statusReport >>= saveCert domainCertFile |
141 | -- Send a CSR and get a certificate | ||
142 | void $ saveCert csrData domainCertFile >>= statusReport | ||
143 | |||
144 | else liftIO $ putStrLn "Error" | ||
145 | 130 | ||
146 | where | 131 | where |
147 | a </> b = a ++ "/" ++ b | 132 | a </> b = a ++ "/" ++ b |
133 | |||
134 | extractCR :: MonadReader Env m => Response LC.ByteString -> m ChallengeRequest | ||
135 | extractCR r = do | ||
136 | Keys _ pub <- asks getKeys | ||
137 | let httpChallenge :: (Value -> Const (Endo s) Value) -> Response LC.ByteString -> Const (Endo s) (Response LC.ByteString) | ||
138 | httpChallenge = responseBody . | ||
139 | JSON.key "challenges" . | ||
140 | to universe . | ||
141 | traverse . | ||
142 | (filtered . has $ ix "type" . only "http-01") | ||
143 | |||
144 | token = r ^?! httpChallenge . JSON.key "token" . _String . to encodeUtf8 | ||
145 | crUri = r ^?! httpChallenge . JSON.key "uri" . _String . to T.unpack | ||
146 | |||
147 | thumb = thumbprint (JWK (rsaE pub) "RSA" (rsaN pub)) | ||
148 | thumbtoken = toStrict (LB.fromChunks [token, ".", thumb]) | ||
149 | |||
150 | return $ ChallengeRequest crUri token thumbtoken | ||
151 | |||
152 | ncErrorReport :: (Show body, AsValue body, MonadIO m) => Response body -> m () | ||
153 | ncErrorReport r = | ||
154 | when (Just "pending" /= r ^? responseBody . JSON.key "status" . _String) $ liftIO $ do | ||
155 | putStrLn "Unexpected response to challenge-response request:" | ||
156 | print r | ||
157 | |||
158 | saveCert :: MonadIO m => FilePath -> Response LC.ByteString -> m () | ||
159 | saveCert domainCertFile r = | ||
160 | if isSuccess $ r ^. responseStatus . statusCode | ||
161 | then liftIO $ LC.writeFile domainCertFile $ r ^. responseBody | ||
162 | else liftIO $ do | ||
163 | let (summary, details) = (k "type", k "detail") | ||
164 | k x = r ^?! responseBody . JSON.key x . _String . to T.unpack | ||
165 | liftIO $ putStrLn $ summary ++ " ---- " ++ details | ||
166 | where | ||
148 | isSuccess n = n >= 200 && n <= 300 | 167 | isSuccess n = n >= 200 && n <= 300 |
149 | 168 | ||
150 | saveCert :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => ByteString -> FilePath -> m (Response LC.ByteString) | 169 | retrieveCert :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => ByteString -> m (Response LC.ByteString) |
151 | saveCert input output = do | 170 | retrieveCert input = sendPayload _newCert (csr input) |
152 | r <- sendPayload _newCert (csr input) | ||
153 | liftIO $ LC.writeFile output $ r ^. responseBody | ||
154 | return r | ||
155 | 171 | ||
156 | pollChallenge :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> ByteString -> m (Response LC.ByteString) | 172 | notifyChallenge :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> ByteString -> m (Response LC.ByteString) |
157 | pollChallenge crUri thumbtoken = loop | 173 | notifyChallenge crUri thumbtoken = sendPayload (const crUri) (challenge thumbtoken) |
158 | where | ||
159 | loop = do | ||
160 | liftIO . threadDelay $ 2000 * 1000 | ||
161 | liftIO $ putStrLn "polling..." | ||
162 | r <- sendPayload (const crUri) (challenge thumbtoken) >>= statusReport | ||
163 | let status = r ^? responseBody . JSON.key "status" . _String | ||
164 | if status == Just "pending" | ||
165 | then do | ||
166 | liftIO . print $ r ^. responseBody | ||
167 | liftIO . print $ r ^? responseBody . JSON.key "keyAuthorization" . _String | ||
168 | liftIO . threadDelay $ 2000 * 1000 | ||
169 | loop | ||
170 | else do | ||
171 | liftIO $ putStrLn "done polling." | ||
172 | liftIO $ print r | ||
173 | return r | ||
174 | 174 | ||
175 | data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session } | 175 | data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session } |
176 | 176 | ||
@@ -187,14 +187,9 @@ data Directory = Directory { | |||
187 | _newReg :: String | 187 | _newReg :: String |
188 | } | 188 | } |
189 | newtype Nonce = Nonce String | 189 | newtype Nonce = Nonce String |
190 | testRegister :: String -> IO (Response LC.ByteString) | ||
191 | testRegister email = runTest (register defaultTerms email) >>= statusReport | ||
192 | 190 | ||
193 | runTest :: ACME b -> IO b | 191 | runTest :: ACME b -> IO b |
194 | runTest t = readKeys "rsa.key" >>= flip (runACME directoryUrl) t | 192 | runTest t = readKeys "rsa.key" >>= flip (runACME stagingDirectoryUrl) t |
195 | |||
196 | testCR :: IO (Response LC.ByteString) | ||
197 | testCR = runTest $ challengeRequest "fifty.childrenofmay.org" | ||
198 | 193 | ||
199 | getDirectory :: WS.Session -> String -> IO (Maybe (Directory, Nonce)) | 194 | getDirectory :: WS.Session -> String -> IO (Maybe (Directory, Nonce)) |
200 | getDirectory sess url = do | 195 | getDirectory sess url = do |
@@ -225,7 +220,7 @@ sendPayload reqType payload = do | |||
225 | signed <- liftIO $ signPayload keys nonce payload | 220 | signed <- liftIO $ signPayload keys nonce payload |
226 | post (reqType dir) signed | 221 | post (reqType dir) signed |
227 | 222 | ||
228 | -- post :: (MonadReader Env m, MonadState Nonce m, MonadIO m, Postable a) => String -> a -> m (Response LC.ByteString) | 223 | post :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> LC.ByteString -> m (Response LC.ByteString) |
229 | post url payload = do | 224 | post url payload = do |
230 | sess <- asks getSession | 225 | sess <- asks getSession |
231 | r <- liftIO $ WS.postWith noStatusCheck sess url payload | 226 | r <- liftIO $ WS.postWith noStatusCheck sess url payload |