summaryrefslogtreecommitdiff
path: root/src/Propellor/Property
diff options
context:
space:
mode:
authorJoey Hess2016-02-07 16:28:48 -0400
committerJoey Hess2016-02-07 16:34:48 -0400
commitb845b1c5efc1362dc78baf87747ba8b90fcd97dd (patch)
tree1fa91d79f21c712f5d94081e059107c617392b8d /src/Propellor/Property
parentf6e9216d08326695bf4f873496ce8a08376f7d4a (diff)
letsencrypt
* Added Propellor.Property.LetsEncrypt * Apache.httpsVirtualHost: New property, setting up a https vhost with the certificate automatically obtained using letsencrypt.
Diffstat (limited to 'src/Propellor/Property')
-rw-r--r--src/Propellor/Property/Apache.hs134
-rw-r--r--src/Propellor/Property/LetsEncrypt.hs115
2 files changed, 210 insertions, 39 deletions
diff --git a/src/Propellor/Property/Apache.hs b/src/Propellor/Property/Apache.hs
index 9e192e84..709c1753 100644
--- a/src/Propellor/Property/Apache.hs
+++ b/src/Propellor/Property/Apache.hs
@@ -4,6 +4,7 @@ import Propellor.Base
import qualified Propellor.Property.File as File
import qualified Propellor.Property.Apt as Apt
import qualified Propellor.Property.Service as Service
+import qualified Propellor.Property.LetsEncrypt as LetsEncrypt
installed :: Property NoInfo
installed = Apt.installed ["apache2"]
@@ -14,48 +15,35 @@ restarted = Service.restarted "apache2"
reloaded :: Property NoInfo
reloaded = Service.reloaded "apache2"
--- | A basic virtual host, publishing a directory, and logging to
--- the combined apache log file.
-virtualHost :: HostName -> Port -> FilePath -> RevertableProperty NoInfo
-virtualHost hn (Port p) docroot = siteEnabled hn
- [ "<VirtualHost *:"++show p++">"
- , "ServerName "++hn++":"++show p
- , "DocumentRoot " ++ docroot
- , "ErrorLog /var/log/apache2/error.log"
- , "LogLevel warn"
- , "CustomLog /var/log/apache2/access.log combined"
- , "ServerSignature On"
- , "</VirtualHost>"
- ]
-
type ConfigFile = [String]
-siteEnabled :: HostName -> ConfigFile -> RevertableProperty NoInfo
-siteEnabled hn cf = enable <!> disable
- where
- enable = combineProperties ("apache site enabled " ++ hn)
- [ siteAvailable hn cf
+siteEnabled :: Domain -> ConfigFile -> RevertableProperty NoInfo
+siteEnabled domain cf = siteEnabled' domain cf <!> siteDisabled domain
+
+siteEnabled' :: Domain -> ConfigFile -> Property NoInfo
+siteEnabled' domain cf = combineProperties ("apache site enabled " ++ domain)
+ [ siteAvailable domain cf
+ `requires` installed
+ `onChange` reloaded
+ , check (not <$> isenabled)
+ (cmdProperty "a2ensite" ["--quiet", domain])
`requires` installed
`onChange` reloaded
- , check (not <$> isenabled)
- (cmdProperty "a2ensite" ["--quiet", hn])
- `requires` installed
- `onChange` reloaded
- ]
- disable = siteDisabled hn
- isenabled = boolSystem "a2query" [Param "-q", Param "-s", Param hn]
-
-siteDisabled :: HostName -> Property NoInfo
-siteDisabled hn = combineProperties
- ("apache site disabled " ++ hn)
- (map File.notPresent (siteCfg hn))
- `onChange` (cmdProperty "a2dissite" ["--quiet", hn] `assume` MadeChange)
+ ]
+ where
+ isenabled = boolSystem "a2query" [Param "-q", Param "-s", Param domain]
+
+siteDisabled :: Domain -> Property NoInfo
+siteDisabled domain = combineProperties
+ ("apache site disabled " ++ domain)
+ (map File.notPresent (siteCfg domain))
+ `onChange` (cmdProperty "a2dissite" ["--quiet", domain] `assume` MadeChange)
`requires` installed
`onChange` reloaded
-siteAvailable :: HostName -> ConfigFile -> Property NoInfo
-siteAvailable hn cf = combineProperties ("apache site available " ++ hn) $
- map (`File.hasContent` (comment:cf)) (siteCfg hn)
+siteAvailable :: Domain -> ConfigFile -> Property NoInfo
+siteAvailable domain cf = combineProperties ("apache site available " ++ domain) $
+ map (`File.hasContent` (comment:cf)) (siteCfg domain)
where
comment = "# deployed with propellor, do not modify"
@@ -86,12 +74,12 @@ listenPorts ps = "/etc/apache2/ports.conf" `File.hasContent` map portline ps
-- This is a list of config files because different versions of apache
-- use different filenames. Propellor simply writes them all.
-siteCfg :: HostName -> [FilePath]
-siteCfg hn =
+siteCfg :: Domain -> [FilePath]
+siteCfg domain =
-- Debian pre-2.4
- [ "/etc/apache2/sites-available/" ++ hn
+ [ "/etc/apache2/sites-available/" ++ domain
-- Debian 2.4+
- , "/etc/apache2/sites-available/" ++ hn ++ ".conf"
+ , "/etc/apache2/sites-available/" ++ domain ++ ".conf"
]
-- | Configure apache to use SNI to differentiate between
@@ -123,3 +111,71 @@ allowAll = unlines
, "Require all granted"
, "</IfVersion>"
]
+
+type WebRoot = FilePath
+
+-- | A basic virtual host, publishing a directory, and logging to
+-- the combined apache log file. Not https capable.
+virtualHost :: Domain -> Port -> WebRoot -> RevertableProperty NoInfo
+virtualHost domain (Port p) docroot = siteEnabled domain
+ [ "<VirtualHost *:"++show p++">"
+ , "ServerName "++domain++":"++show p
+ , "DocumentRoot " ++ docroot
+ , "ErrorLog /var/log/apache2/error.log"
+ , "LogLevel warn"
+ , "CustomLog /var/log/apache2/access.log combined"
+ , "ServerSignature On"
+ , "</VirtualHost>"
+ ]
+
+-- | A virtual host using https, with the certificate obtained
+-- using `Propellor.Property.LetsEncrypt.letsEncrypt`.
+--
+-- http connections are redirected to https.
+--
+-- Example:
+--
+-- > httpsVirtualHost "example.com" "/var/www"
+-- > (LetsEncrypt.AgreeTos (Just "me@my.domain"))
+httpsVirtualHost :: Domain -> WebRoot -> LetsEncrypt.AgreeTOS -> Property NoInfo
+httpsVirtualHost domain docroot letos = setup
+ `requires` modEnabled "rewrite"
+ `requires` modEnabled "ssl"
+ `before` LetsEncrypt.letsEncrypt letos domain docroot certinstaller
+ where
+ setup = siteEnabled' domain $
+ -- The sslconffile is only created after letsencrypt gets
+ -- the cert. The "*" is needed to make apache not error
+ -- when the file doesn't exist.
+ ("IncludeOptional " ++ sslconffile "*")
+ : vhost (Port 80)
+ [ "RewriteEngine On"
+ -- Pass through .well-known directory on http for the
+ -- letsencrypt acme challenge.
+ , "RewriteRule ^/.well-known/(.*) - [L]"
+ -- Everything else redirects to https
+ , "RewriteRule ^/(.*) https://" ++ domain ++ "/$1 [L,R,NE]"
+ ]
+ certinstaller _domain certfile privkeyfile chainfile _fullchainfile =
+ File.hasContent (sslconffile "letsencrypt")
+ ( vhost (Port 443)
+ [ "SSLEngine on"
+ , "SSLCertificateFile " ++ certfile
+ , "SSLCertificateKeyFile" ++ privkeyfile
+ , "SSLCertificateChainFile " ++ chainfile
+ ]
+ )
+ -- always reload; the cert has changed
+ `before` reloaded
+ sslconffile s = "/etc/apache2/sites-available/ssl/" ++ domain ++ "/" ++ s ++ ".conf"
+ vhost (Port p) ls =
+ [ "<VirtualHost *:"++show p++">"
+ , "ServerName "++domain++":"++show p
+ , "DocumentRoot " ++ docroot
+ , "ErrorLog /var/log/apache2/error.log"
+ , "LogLevel warn"
+ , "CustomLog /var/log/apache2/access.log combined"
+ , "ServerSignature On"
+ ] ++ ls ++
+ [ "</VirtualHost>"
+ ]
diff --git a/src/Propellor/Property/LetsEncrypt.hs b/src/Propellor/Property/LetsEncrypt.hs
new file mode 100644
index 00000000..651cffd9
--- /dev/null
+++ b/src/Propellor/Property/LetsEncrypt.hs
@@ -0,0 +1,115 @@
+-- | This module uses the letsencrypt reference client.
+
+module Propellor.Property.LetsEncrypt where
+
+import Propellor.Base
+import qualified Propellor.Property.Apt as Apt
+
+import System.Posix.Files
+
+installed :: Property NoInfo
+installed = Apt.installed ["letsencrypt"]
+
+-- | Tell the letsencrypt client that you agree with the Let's Encrypt
+-- Subscriber Agreement. Providing an email address is recommended,
+-- so that letcencrypt can contact you about problems.
+data AgreeTOS = AgreeTOS (Maybe Email)
+
+type Email = String
+
+type WebRoot = FilePath
+
+-- | Uses letsencrypt to obtain a certificate for a domain.
+--
+-- This should work with any web server, as long as letsencrypt can
+-- write its temp files to the web root. The letsencrypt client does
+-- not modify the web server's configuration in any way; instead the
+-- `CertInstaller` is used once the client has successfully obtained the
+-- certificate.
+--
+-- This also handles renewing the certificate, and the `CertInstaller` is
+-- also run after renewal. For renewel to work well, propellor needs to be
+-- run periodically (at least a couple times per month).
+--
+-- See `Propellor.Property.Apache.httpsVirtualHost` for a property built using this.
+letsEncrypt :: AgreeTOS -> Domain -> WebRoot -> CertInstaller -> Property NoInfo
+letsEncrypt tos domain = letsEncrypt' tos domain []
+
+-- | Like `letsEncrypt`, but the certificate can be obtained for multiple
+-- domains.
+letsEncrypt' :: AgreeTOS -> Domain -> [Domain] -> WebRoot -> CertInstaller -> Property NoInfo
+letsEncrypt' (AgreeTOS memail) domain domains webroot certinstaller =
+ prop `requires` installed
+ where
+ prop = property desc $ do
+ startstats <- liftIO getstats
+ (transcript, ok) <- liftIO $
+ processTranscript "letsencrypt" params Nothing
+ if ok
+ then do
+ endstats <- liftIO getstats
+ if startstats == endstats
+ then return NoChange
+ else ensureProperty certsinstalled
+ else do
+ liftIO $ hPutStr stderr transcript
+ return FailedChange
+
+ desc = "letsencrypt " ++ unwords alldomains
+ alldomains = domain : domains
+ params =
+ [ "certonly"
+ , "--agree-tos"
+ , case memail of
+ Just email -> "--email="++email
+ Nothing -> "--register-unsafely-without-email"
+ , "--webroot"
+ , "--webroot-path", webroot
+ , "--text"
+ , "--keep-until-expiring"
+ ] ++ map (\d -> "--domain="++d) alldomains
+
+ getstats = mapM statcertfiles alldomains
+ statcertfiles d = mapM statfile
+ [ certFile d
+ , privKeyFile d
+ , chainFile d
+ , fullChainFile d
+ ]
+ statfile f = catchMaybeIO $ do
+ s <- getFileStatus f
+ return (fileID s, deviceID s, fileMode s, fileSize s, modificationTime s)
+
+ certsinstalled = propertyList ("certs installed") $
+ flip map alldomains $ \d -> certinstaller d
+ (certFile d)
+ (privKeyFile d)
+ (chainFile d)
+ (fullChainFile d)
+
+-- | A property that installs a certificate, once letsencrypt obtains it.
+--
+-- For example, it could configure the web server to use the certificate
+-- files, and restart the web server.
+type CertInstaller = Domain -> CertFile -> PrivKeyFile -> ChainFile -> FullChainFile -> Property NoInfo
+
+-- | Locations of certificate files generated by lets encrypt.
+type CertFile = FilePath
+type PrivKeyFile = FilePath
+type ChainFile = FilePath
+type FullChainFile = FilePath
+
+liveCertDir :: Domain -> FilePath
+liveCertDir d = "/etc/letsencrypt/live" </> d
+
+certFile :: Domain -> FilePath
+certFile d = liveCertDir d </> "cert.pem"
+
+privKeyFile :: Domain -> FilePath
+privKeyFile d = liveCertDir d </> "privkey.pem"
+
+chainFile :: Domain -> FilePath
+chainFile d = liveCertDir d </> "chain.pem"
+
+fullChainFile :: Domain -> FilePath
+fullChainFile d = liveCertDir d </> "fullchain.pem"