From 18799d142947cd1aa936ce10df6fb25e0b20abe0 Mon Sep 17 00:00:00 2001 From: Tim Docker Date: Fri, 9 Nov 2018 10:25:37 +1100 Subject: [PATCH] Added a subcommmand to generate ssl certificates using letsencrypt http challenges handled by the frontend proxy --- adl/config.adl | 24 ++++++- adl/config.adl-hs | 2 + src/ADL/Config.hs | 68 +++++++++++++++--- src/Commands/ProxyMode.hs | 14 +++- src/Commands/ProxyMode/LocalState.hs | 102 +++++++++++++++++++++------ src/Main.hs | 2 + 6 files changed, 175 insertions(+), 37 deletions(-) diff --git a/adl/config.adl b/adl/config.adl index 041bdd9..96914ce 100644 --- a/adl/config.adl +++ b/adl/config.adl @@ -12,6 +12,13 @@ struct ToolConfig { FilePath releasesDir = "/opt/releases"; FilePath contextCache = "/opt/etc/deployment"; FilePath logFile = "/opt/var/log/hx-deploy-tool.log"; + FilePath letsencryptPrefixDir = "/opt"; + FilePath letsencryptWwwDir = "/opt/var/www"; + + /// If the deploy tool needs to generate an SSL certificate + /// using letsencrypt, it will be called this. + String autoCertName = "hxdeploytoolcert"; + String autoCertContactEmail = ""; /// The storage location for release zip files BlobStoreConfig releases; @@ -57,13 +64,26 @@ union MachineLabel { struct EndPoint { EndPointLabel label; String serverName; - String sslCertDir; EndPointType etype; }; union EndPointType { Void httpOnly; - Void httpsWithRedirect; + SslCertMode httpsWithRedirect; +}; + +union SslCertMode { + /// Use letsencrypt to generate a certificate + Void generated; + + /// Use the existing certificate from the given file system + /// paths + SslCertPaths explicit; +}; + +struct SslCertPaths { + FilePath sslCertificate; + FilePath sslCertificateKey; }; struct DeployContextFile diff --git a/adl/config.adl-hs b/adl/config.adl-hs index 91e3ca1..92f2e98 100644 --- a/adl/config.adl-hs +++ b/adl/config.adl-hs @@ -8,4 +8,6 @@ annotation EndPointType HaskellFieldPrefix "ep_"; annotation ToolConfig HaskellFieldPrefix "tc_"; annotation ProxyModeConfig HaskellFieldPrefix "pm_"; annotation LetsEncryptConfig HaskellFieldPrefix "lec_"; +annotation SslCertMode HaskellFieldPrefix "scm_"; +annotation SslCertPaths HaskellFieldPrefix "scp_"; }; diff --git a/src/ADL/Config.hs b/src/ADL/Config.hs index 54e3007..f514da8 100644 --- a/src/ADL/Config.hs +++ b/src/ADL/Config.hs @@ -8,6 +8,8 @@ module ADL.Config( LetsEncryptConfig(..), MachineLabel(..), ProxyModeConfig(..), + SslCertMode(..), + SslCertPaths(..), ToolConfig(..), Verbosity(..), ) where @@ -84,13 +86,12 @@ instance AdlValue DeployMode where data EndPoint = EndPoint { ep_label :: ADL.Types.EndPointLabel , ep_serverName :: T.Text - , ep_sslCertDir :: T.Text , ep_etype :: EndPointType } deriving (Prelude.Eq,Prelude.Ord,Prelude.Show) -mkEndPoint :: ADL.Types.EndPointLabel -> T.Text -> T.Text -> EndPointType -> EndPoint -mkEndPoint label serverName sslCertDir etype = EndPoint label serverName sslCertDir etype +mkEndPoint :: ADL.Types.EndPointLabel -> T.Text -> EndPointType -> EndPoint +mkEndPoint label serverName etype = EndPoint label serverName etype instance AdlValue EndPoint where atype _ = "config.EndPoint" @@ -98,19 +99,17 @@ instance AdlValue EndPoint where jsonGen = genObject [ genField "label" ep_label , genField "serverName" ep_serverName - , genField "sslCertDir" ep_sslCertDir , genField "etype" ep_etype ] jsonParser = EndPoint <$> parseField "label" <*> parseField "serverName" - <*> parseField "sslCertDir" <*> parseField "etype" data EndPointType = Ep_httpOnly - | Ep_httpsWithRedirect + | Ep_httpsWithRedirect SslCertMode deriving (Prelude.Eq,Prelude.Ord,Prelude.Show) instance AdlValue EndPointType where @@ -118,12 +117,12 @@ instance AdlValue EndPointType where jsonGen = genUnion (\jv -> case jv of Ep_httpOnly -> genUnionVoid "httpOnly" - Ep_httpsWithRedirect -> genUnionVoid "httpsWithRedirect" + Ep_httpsWithRedirect v -> genUnionValue "httpsWithRedirect" v ) jsonParser = parseUnionVoid "httpOnly" Ep_httpOnly - <|> parseUnionVoid "httpsWithRedirect" Ep_httpsWithRedirect + <|> parseUnionValue "httpsWithRedirect" Ep_httpsWithRedirect <|> parseFail "expected a EndPointType" data LetsEncryptConfig = LetsEncryptConfig @@ -204,10 +203,53 @@ instance AdlValue ProxyModeConfig where <*> parseFieldDef "dynamicPortRange" ((,) 8000 8100) <*> parseFieldDef "slaveLabel" MachineLabel_ec2InstanceId +data SslCertMode + = Scm_generated + | Scm_explicit SslCertPaths + deriving (Prelude.Eq,Prelude.Ord,Prelude.Show) + +instance AdlValue SslCertMode where + atype _ = "config.SslCertMode" + + jsonGen = genUnion (\jv -> case jv of + Scm_generated -> genUnionVoid "generated" + Scm_explicit v -> genUnionValue "explicit" v + ) + + jsonParser + = parseUnionVoid "generated" Scm_generated + <|> parseUnionValue "explicit" Scm_explicit + <|> parseFail "expected a SslCertMode" + +data SslCertPaths = SslCertPaths + { scp_sslCertificate :: ADL.Types.FilePath + , scp_sslCertificateKey :: ADL.Types.FilePath + } + deriving (Prelude.Eq,Prelude.Ord,Prelude.Show) + +mkSslCertPaths :: ADL.Types.FilePath -> ADL.Types.FilePath -> SslCertPaths +mkSslCertPaths sslCertificate sslCertificateKey = SslCertPaths sslCertificate sslCertificateKey + +instance AdlValue SslCertPaths where + atype _ = "config.SslCertPaths" + + jsonGen = genObject + [ genField "sslCertificate" scp_sslCertificate + , genField "sslCertificateKey" scp_sslCertificateKey + ] + + jsonParser = SslCertPaths + <$> parseField "sslCertificate" + <*> parseField "sslCertificateKey" + data ToolConfig = ToolConfig { tc_releasesDir :: ADL.Types.FilePath , tc_contextCache :: ADL.Types.FilePath , tc_logFile :: ADL.Types.FilePath + , tc_letsencryptPrefixDir :: ADL.Types.FilePath + , tc_letsencryptWwwDir :: ADL.Types.FilePath + , tc_autoCertName :: T.Text + , tc_autoCertContactEmail :: T.Text , tc_releases :: BlobStoreConfig , tc_deployContext :: BlobStoreConfig , tc_deployContextFiles :: [DeployContextFile] @@ -216,7 +258,7 @@ data ToolConfig = ToolConfig deriving (Prelude.Eq,Prelude.Ord,Prelude.Show) mkToolConfig :: BlobStoreConfig -> BlobStoreConfig -> [DeployContextFile] -> ToolConfig -mkToolConfig releases deployContext deployContextFiles = ToolConfig "/opt/releases" "/opt/etc/deployment" "/opt/var/log/hx-deploy-tool.log" releases deployContext deployContextFiles DeployMode_select +mkToolConfig releases deployContext deployContextFiles = ToolConfig "/opt/releases" "/opt/etc/deployment" "/opt/var/log/hx-deploy-tool.log" "/opt" "/opt/var/www" "hxdeploytoolcert" "" releases deployContext deployContextFiles DeployMode_select instance AdlValue ToolConfig where atype _ = "config.ToolConfig" @@ -225,6 +267,10 @@ instance AdlValue ToolConfig where [ genField "releasesDir" tc_releasesDir , genField "contextCache" tc_contextCache , genField "logFile" tc_logFile + , genField "letsencryptPrefixDir" tc_letsencryptPrefixDir + , genField "letsencryptWwwDir" tc_letsencryptWwwDir + , genField "autoCertName" tc_autoCertName + , genField "autoCertContactEmail" tc_autoCertContactEmail , genField "releases" tc_releases , genField "deployContext" tc_deployContext , genField "deployContextFiles" tc_deployContextFiles @@ -235,6 +281,10 @@ instance AdlValue ToolConfig where <$> parseFieldDef "releasesDir" "/opt/releases" <*> parseFieldDef "contextCache" "/opt/etc/deployment" <*> parseFieldDef "logFile" "/opt/var/log/hx-deploy-tool.log" + <*> parseFieldDef "letsencryptPrefixDir" "/opt" + <*> parseFieldDef "letsencryptWwwDir" "/opt/var/www" + <*> parseFieldDef "autoCertName" "hxdeploytoolcert" + <*> parseFieldDef "autoCertContactEmail" "" <*> parseField "releases" <*> parseField "deployContext" <*> parseField "deployContextFiles" diff --git a/src/Commands/ProxyMode.hs b/src/Commands/ProxyMode.hs index 1eced0a..e26a2da 100644 --- a/src/Commands/ProxyMode.hs +++ b/src/Commands/ProxyMode.hs @@ -7,7 +7,8 @@ module Commands.ProxyMode( connect, disconnect, slaveUpdate, - restartProxy + restartProxy, + generateSslCertificate ) where import qualified ADL.Core.StringMap as SM @@ -29,7 +30,7 @@ import ADL.State(State(..), Deploy(..)) import ADL.Types(EndPointLabel, DeployLabel) import Util(unpackRelease,fetchDeployContext, checkReleaseExists) import Commands.ProxyMode.Types -import Commands.ProxyMode.LocalState(localState, restartLocalProxy) +import Commands.ProxyMode.LocalState(localState, restartLocalProxy, generateLocalSslCertificate) import Commands.ProxyMode.RemoteState(remoteState, writeSlaveState, masterS3Path) import Control.Concurrent(threadDelay) import Control.Exception(SomeException) @@ -66,7 +67,7 @@ showStatus showSlaves = do for_ (pmEndPoints pm) $ \ep -> do let etype = case ep_etype ep of Ep_httpOnly -> "(" <> ep_serverName ep <> ":80)" - Ep_httpsWithRedirect -> "(" <> ep_serverName ep <> ":80,442)" + Ep_httpsWithRedirect _ -> "(" <> ep_serverName ep <> ":80,442)" let connected = case SM.lookup (ep_label ep) (s_connections state) of Nothing -> "(not connected)" Just deployLabel -> deployLabel @@ -212,6 +213,13 @@ restartProxy = do Nothing -> restartLocalProxy _ -> return () +generateSslCertificate :: IOR () +generateSslCertificate = do + pm <- getProxyModeConfig + case pm_remoteStateS3 pm of + Nothing -> generateLocalSslCertificate + _ -> return () + getSlaveLabel :: IOR T.Text getSlaveLabel = do diff --git a/src/Commands/ProxyMode/LocalState.hs b/src/Commands/ProxyMode/LocalState.hs index fd2c37a..2dbcc60 100644 --- a/src/Commands/ProxyMode/LocalState.hs +++ b/src/Commands/ProxyMode/LocalState.hs @@ -1,7 +1,8 @@ {-# LANGUAGE OverloadedStrings #-} module Commands.ProxyMode.LocalState( localState, - restartLocalProxy + restartLocalProxy, + generateLocalSslCertificate ) where import qualified ADL.Core.StringMap as SM @@ -15,11 +16,12 @@ import qualified Data.Set as S import ADL.Config(EndPoint(..), EndPointType(..)) import ADL.Core(adlFromJsonFile', adlToJsonFile) import ADL.Release(ReleaseConfig(..)) -import ADL.Config(ToolConfig(..), DeployContextFile(..), DeployMode(..), ProxyModeConfig(..)) +import ADL.Config(ToolConfig(..), DeployContextFile(..), DeployMode(..), ProxyModeConfig(..), SslCertMode(..), SslCertPaths(..)) import ADL.State(State(..), Deploy(..)) import ADL.Types(EndPointLabel, DeployLabel) import Commands.ProxyMode.Types import Util(unpackRelease,fetchDeployContext) +import Control.Monad(when) import Control.Monad.Reader(ask) import Control.Monad.IO.Class import Data.List(find) @@ -30,7 +32,7 @@ import Data.Word import System.Directory(createDirectoryIfMissing,doesFileExist,doesDirectoryExist,withCurrentDirectory, removeDirectoryRecursive) import System.FilePath(takeBaseName, takeDirectory, dropExtension, ()) import System.Process(callCommand) -import Types(IOR, REnv(..), getToolConfig, scopeInfo) +import Types(IOR, REnv(..), getToolConfig, scopeInfo, info) localState :: StateAccess localState = StateAccess { @@ -115,11 +117,12 @@ executeAction (DestroyDeploy d) = do executeAction (SetEndPoints liveEndPoints) = do scopeInfo "execute SetEndPoints" $ do + tcfg <- getToolConfig allEndPoints <- fmap pm_endPoints getProxyModeConfig proxyDir <- getProxyDir scopeInfo "writing proxy config files" $ liftIO $ do - writeProxyDockerCompose (proxyDir "docker-compose.yml") - writeNginxConfig (proxyDir "nginx.conf") (maybeEndpoints allEndPoints liveEndPoints) + writeProxyDockerCompose tcfg (proxyDir "docker-compose.yml") + writeNginxConfig tcfg (proxyDir "nginx.conf") (maybeEndpoints allEndPoints liveEndPoints) callCommandInDir proxyDir "docker-compose up -d" callCommandInDir proxyDir "docker kill --signal=SIGHUP frontendproxy" where @@ -150,14 +153,56 @@ getStateFile = do restartLocalProxy :: IOR () restartLocalProxy = do - state <- getState - pm <- getProxyModeConfig - let endPoints = (catMaybes . map (getEndpointDeploy endPointMap state) . SM.toList) (s_connections state) - endPointMap = SM.toMap (pm_endPoints pm) - executeAction (SetEndPoints endPoints) + scopeInfo "restarting local proxy" $ do + state <- getState + pm <- getProxyModeConfig + let endPoints = (catMaybes . map (getEndpointDeploy endPointMap state) . SM.toList) (s_connections state) + endPointMap = SM.toMap (pm_endPoints pm) + executeAction (SetEndPoints endPoints) + +--- If any endpoints are configured for an autogenerated certificate, +--- generate a single certificate that covers them all. Use certbot +--- running under docker, in webroot mode such that it can answer the +--- challenges with the running local proxy. +generateLocalSslCertificate :: IOR () +generateLocalSslCertificate = do + scopeInfo "generating certificate with letsencrypt" $ do + restartLocalProxy + tcfg <- getToolConfig + pm <- getProxyModeConfig + proxyDir <- getProxyDir + let serverNames = [serverName | + EndPoint{ep_serverName=serverName,ep_etype=Ep_httpsWithRedirect Scm_generated} <- M.elems (SM.toMap (pm_endPoints pm))] + ledir = tc_letsencryptPrefixDir tcfg + lewwwdir = tc_letsencryptWwwDir tcfg + certbotCmd = T.intercalate " " ( + [ "docker run --rm --name " <> tc_autoCertName tcfg + , "-v \"" <> ledir <> "/etc/letsencrypt:/etc/letsencrypt\"" + , "-v \"" <> ledir <> "/var/log/letsencrypt:/var/log/letsencrypt\"" + , "-v \"" <> lewwwdir <> "/.well-known/acme-challenge:" <> lewwwdir <> "/.well-known/acme-challenge\"" + , "certbot/certbot certonly" + , "--cert-name " <> tc_autoCertName tcfg + , "--preferred-challenge http-01" + , "--webroot --webroot-path " <> lewwwdir + , "-m " <> tc_autoCertContactEmail tcfg + , "--deploy-hook 'chmod -R ag+rX /etc/letsencrypt'" + , "--non-interactive" + , "--agree-tos" + , "--debug" + ] <> + [ "-d " <> serverName | serverName <- serverNames] + ) + + when (tc_autoCertContactEmail tcfg == "") $ do + error "Need autoCertContactEmail specified in the config" -writeProxyDockerCompose :: FilePath -> IO () -writeProxyDockerCompose path = T.writeFile path (T.intercalate "\n" lines) + case serverNames of + [] -> info "No servers requiring a certificate" + _ -> do + callCommandInDir proxyDir certbotCmd + +writeProxyDockerCompose :: ToolConfig -> FilePath -> IO () +writeProxyDockerCompose tcfg path = T.writeFile path (T.intercalate "\n" lines) where lines = [ "version: '2'" @@ -168,11 +213,14 @@ writeProxyDockerCompose path = T.writeFile path (T.intercalate "\n" lines) , " network_mode: host" , " volumes:" , " - ./nginx.conf:/etc/nginx/nginx.conf" - , " - /etc/letsencrypt:/etc/letsencrypt" + , " - " <> ledir <> "/etc/letsencrypt:" <> ledir <> "/etc/letsencrypt" + , " - " <> lewwwdir <> ":" <> lewwwdir ] + ledir = tc_letsencryptPrefixDir tcfg + lewwwdir = tc_letsencryptWwwDir tcfg -writeNginxConfig :: FilePath -> [(EndPoint,Maybe Deploy)] -> IO () -writeNginxConfig path eps = T.writeFile path (T.intercalate "\n" lines) +writeNginxConfig :: ToolConfig -> FilePath -> [(EndPoint,Maybe Deploy)] -> IO () +writeNginxConfig tcfg path eps = T.writeFile path (T.intercalate "\n" lines) where lines = [ "user nginx;" @@ -237,13 +285,13 @@ writeNginxConfig path eps = T.writeFile path (T.intercalate "\n" lines) , " return 503;" , " }" ] - serverBlock (ep@EndPoint{ep_etype=Ep_httpsWithRedirect},Just d) = + serverBlock (ep@EndPoint{ep_etype=Ep_httpsWithRedirect certMode},Just d) = [ " server {" , " listen 80;" , " server_name " <> ep_serverName ep <> ";" , " location '/.well-known/acme-challenge' {" - , " default_type "text/plain";" - , " alias /opt/var/www/.well-known/acme-challenge;" + , " default_type \"text/plain\";" + , " alias " <> tc_letsencryptWwwDir tcfg <> "/.well-known/acme-challenge;" , " }" , " location / {" , " return 301 https://$server_name$request_uri;" @@ -252,21 +300,21 @@ writeNginxConfig path eps = T.writeFile path (T.intercalate "\n" lines) , " server {" , " listen 443 ssl;" , " server_name " <> ep_serverName ep <> ";" - , " ssl_certificate " <> ep_sslCertDir ep <> "/fullchain.pem;" - , " ssl_certificate_key " <> ep_sslCertDir ep <> "/privkey.pem;" + , " ssl_certificate " <> sslCertPath certMode <> ";" + , " ssl_certificate_key " <> sslCertKeyPath certMode <> ";" , " location / {" , " proxy_set_header Host $host;" , " proxy_pass http://localhost:" <> showText (d_port d) <> "/;" , " }" , " }" ] - serverBlock (ep@EndPoint{ep_etype=Ep_httpsWithRedirect},Nothing) = + serverBlock (ep@EndPoint{ep_etype=Ep_httpsWithRedirect certMode},Nothing) = [ " server {" , " listen 80;" , " server_name " <> ep_serverName ep <> ";" , " location '/.well-known/acme-challenge' {" - , " default_type "text/plain";" - , " alias /opt/var/www/.well-known/acme-challenge;" + , " default_type \"text/plain\";" + , " alias " <> tc_letsencryptWwwDir tcfg <> "/.well-known/acme-challenge;" , " }" , " location / {" , " return 503;" @@ -274,6 +322,14 @@ writeNginxConfig path eps = T.writeFile path (T.intercalate "\n" lines) , " }" ] + sslCertPath :: SslCertMode -> T.Text + sslCertPath (Scm_explicit scp) = scp_sslCertificate scp + sslCertPath Scm_generated = tc_letsencryptPrefixDir tcfg <> "/etc/letsencrypt/live/" <> (tc_autoCertName tcfg) <> "/fullchain.pem" + + sslCertKeyPath :: SslCertMode -> T.Text + sslCertKeyPath (Scm_explicit scp) = scp_sslCertificateKey scp + sslCertKeyPath Scm_generated = tc_letsencryptPrefixDir tcfg <> "/etc/letsencrypt/live/" <> (tc_autoCertName tcfg) <>"/privkey.pem"; + -- Extend the release template context with port variables, -- which include the http port where the deploy will make -- itself available, and some extra ones for general purpose diff --git a/src/Main.hs b/src/Main.hs index 19131f3..862676e 100644 --- a/src/Main.hs +++ b/src/Main.hs @@ -49,6 +49,7 @@ main = do ["proxy-connect", endpoint, deploy] -> runWithConfigAndLog (P.connect (T.pack endpoint) (T.pack deploy)) ["proxy-disconnect", endpoint] -> runWithConfigAndLog (P.disconnect (T.pack endpoint)) ["proxy-restart"] -> runWithConfigAndLog (P.restartProxy) + ["proxy-generate-ssl-certificate"] -> runWithConfigAndLog (P.generateSslCertificate) ["proxy-slave-update"] -> runWithConfigAndLog (P.slaveUpdate Nothing) ["proxy-slave-update", "--repeat", ssecs] -> do secs <- readCheck ssecs @@ -137,6 +138,7 @@ usageText = "\ \ hx-deploy-tool proxy-deploy \n\ \ hx-deploy-tool proxy-undeploy \n\ \ hx-deploy-tool proxy-restart\n\ + \ hx-deploy-tool proxy-generate-ssl-certificate\n\ \ hx-deploy-tool proxy-connect \n\ \ hx-deploy-tool proxy-disconnect \n\ \ hx-deploy-tool proxy-slave-update [--repeat n]\n\