summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Cady <d@jerkface.net>2016-01-21 00:20:54 -0500
committerAndrew Cady <d@jerkface.net>2016-01-21 00:29:42 -0500
commitc7a9033041a0b6eb4006cb195a84233577f817e3 (patch)
treefb0fbacb1255cec659fc22c316a839aa0dfd540b
parente5e066657123c6090ee1e673d87c7c24c2af71b5 (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.hs121
1 files changed, 58 insertions, 63 deletions
diff --git a/acme.hs b/acme.hs
index 6b1fb74..230bc27 100644
--- a/acme.hs
+++ b/acme.hs
@@ -11,7 +11,6 @@
11 11
12module Main where 12module Main where
13 13
14import Control.Concurrent (threadDelay)
15import Control.Lens hiding ((.=)) 14import Control.Lens hiding ((.=))
16import Control.Monad 15import Control.Monad
17import Control.Monad.RWS.Strict 16import Control.Monad.RWS.Strict
@@ -45,11 +44,11 @@ import OpenSSL.RSA
45import Options.Applicative hiding (header) 44import Options.Applicative hiding (header)
46import qualified Options.Applicative as Opt 45import qualified Options.Applicative as Opt
47import System.Directory 46import System.Directory
48import System.Exit
49import System.Process (readProcess) 47import System.Process (readProcess)
50 48
51directoryUrl :: String 49stagingDirectoryUrl, liveDirectoryUrl :: String
52directoryUrl = "https://acme-v01.api.letsencrypt.org/directory" 50liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory"
51stagingDirectoryUrl = "https://acme-staging.api.letsencrypt.org/directory"
53 52
54main :: IO () 53main :: IO ()
55main = execParser opts >>= go 54main = 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
68defaultTerms :: String 68defaultTerms :: 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
78genKey :: String -> IO () 79genKey :: String -> IO ()
79genKey privKeyFile = void $ readProcess "openssl" (words "genrsa -out" ++ [privKeyFile, "4096"]) "" 80genKey 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
94data ChallengeRequest = ChallengeRequest { crUri :: String, crToken :: ByteString, crThumbToken :: ByteString }
95
93go :: CmdOpts -> IO () 96go :: CmdOpts -> IO ()
94go (CmdOpts privKeyFile domain challengeDir email termOverride) = do 97go (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
134extractCR :: MonadReader Env m => Response LC.ByteString -> m ChallengeRequest
135extractCR 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
152ncErrorReport :: (Show body, AsValue body, MonadIO m) => Response body -> m ()
153ncErrorReport 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
158saveCert :: MonadIO m => FilePath -> Response LC.ByteString -> m ()
159saveCert 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
150saveCert :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => ByteString -> FilePath -> m (Response LC.ByteString) 169retrieveCert :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => ByteString -> m (Response LC.ByteString)
151saveCert input output = do 170retrieveCert input = sendPayload _newCert (csr input)
152 r <- sendPayload _newCert (csr input)
153 liftIO $ LC.writeFile output $ r ^. responseBody
154 return r
155 171
156pollChallenge :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> ByteString -> m (Response LC.ByteString) 172notifyChallenge :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> ByteString -> m (Response LC.ByteString)
157pollChallenge crUri thumbtoken = loop 173notifyChallenge 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
175data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session } 175data 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}
189newtype Nonce = Nonce String 189newtype Nonce = Nonce String
190testRegister :: String -> IO (Response LC.ByteString)
191testRegister email = runTest (register defaultTerms email) >>= statusReport
192 190
193runTest :: ACME b -> IO b 191runTest :: ACME b -> IO b
194runTest t = readKeys "rsa.key" >>= flip (runACME directoryUrl) t 192runTest t = readKeys "rsa.key" >>= flip (runACME stagingDirectoryUrl) t
195
196testCR :: IO (Response LC.ByteString)
197testCR = runTest $ challengeRequest "fifty.childrenofmay.org"
198 193
199getDirectory :: WS.Session -> String -> IO (Maybe (Directory, Nonce)) 194getDirectory :: WS.Session -> String -> IO (Maybe (Directory, Nonce))
200getDirectory sess url = do 195getDirectory 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) 223post :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> LC.ByteString -> m (Response LC.ByteString)
229post url payload = do 224post 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