acme-not-a-joke-warp
Automatic HTTPS for warp, in the spirit of Caddy: runs a wai application over
HTTPS with certificates obtained from an ACME server (e.g. Let's Encrypt),
which are stored on disk, renewed before they expire, and served without
restarting.
Built on acme-not-a-joke (the ACME client) and
acme-not-a-joke-wai (HTTP-01 challenges
and the live certificate store for warp-tls).
import Acme.NotAJoke.LetsEncrypt (letsencryptv2)
import Acme.NotAJoke.Warp
import Data.List.NonEmpty (NonEmpty (..))
main :: IO ()
main =
runAutoHttps
(defaultAutoHttps letsencryptv2 ("example.dicioccio.fr" :| []) ["mailto:certmaster@dicioccio.fr"] "state")
application
Using an ACME server means agreeing to its terms of service: the account is
created saying so.
what runs
runAutoHttps runs three things until one of the servers stops:
- an HTTPS server (port 443) running your application. It reads its
certificate from a
CertificateStore at each TLS handshake, and answers the
TLS-ALPN-01 challenges of the ACME server;
- an HTTP server (port 80) answering the HTTP-01 challenges of the ACME
server and redirecting any other request to HTTPS (308). Set
httpApplication to run something else than the redirection, and
serveHttp = False to leave it out (with TLS-ALPN-01 challenges);
- a certificate manager, which looks after one certificate per domain.
The machine must be reachable from the Internet under each domain, on port 80
(the ACME server connects there, whatever httpSettings says: change the port
only when something forwards port 80 to it) and on the HTTPS port.
without port 80: tls-alpn-01
With challengeMethod = TlsAlpn01, the ACME server validates the domains with
TLS-ALPN-01 challenges (RFC-8737): it opens a TLS connection to port 443
asking for the acme-tls/1 protocol, and the HTTPS server presents a
self-signed challenge certificate to this connection only. Port 80 is not
needed:
let cfg = defaultAutoHttps letsencryptv2 ("example.dicioccio.fr" :| []) contacts "state"
runAutoHttps cfg{challengeMethod = TlsAlpn01, serveHttp = False} application
The HTTPS server must then be reachable on port 443 (or behind something
forwarding port 443 without terminating TLS), and keep its TLS 1.3 support
(the default of warp-tls): the challenge is answered over TLS 1.3 only.
Answering the challenges takes over the choice of the protocol (ALPN), which
picks h2 or http/1.1 as warp-tls does: when HTTP/2 is disabled in
httpsSettings, set an onALPNClientSuggest hook choosing http/1.1 with
tweakTls.
For each domain, the manager:
- loads the certificate stored by a previous run, and serves it;
- when there is none, or when it is due for renewal, runs an ACME dance with
an HTTP-01 (or TLS-ALPN-01) challenge, writes the new certificate on disk and puts it in the
CertificateStore: the next TLS connection gets it, established connections
are left alone, nothing is restarted;
- when the dance fails, reports the failure and tries again later.
Until the first certificate is there (a few seconds when all goes well), TLS
handshakes fail: the server has nothing to present.
when
| what |
default |
setting (in ManagerSettings) |
| renewal |
when two thirds of the validity period have elapsed (30 days before the end of a 90-days certificate) |
renewAt, e.g. renewBefore (20 * 86400) |
| retry after a failure |
2 minutes, then twice as long after each failure in a row, up to 6 hours |
retryDelay |
| looking at the clock |
at least every hour |
checkInterval |
A stored certificate which is due for renewal but still valid is served while
the manager tries to renew it. A failure never stops the servers and is not
thrown: errors of the ACME server, network failures, and errors while reading
or writing files all are reported as an Event to onEvent (which prints a
line on stderr by default).
ACME providers limit the number of failed validations (five per hour and per
name at Let's Encrypt): keep that in mind when changing retryDelay, and use
the staging environment (staging_letsencryptv2) while testing.
where things are stored
Everything is in the state directory (the last argument of
defaultAutoHttps), which is created if missing:
| file |
role |
account.jwk |
the ACME account key, created on the first run (and so is the account) |
{domain}/key.pem |
the private key of the certificate (RSA, PKCS#1), created on the first run and reused for renewals |
{domain}/certificate.pem |
the certificate chain, leaf certificate first, replaced at each renewal |
The CSR is built in memory at each dance. Files are written aside and renamed,
so that a reader never sees half a file. key.pem and certificate.pem are in
the formats that warp-tls, nginx, haproxy etc. read.
The directory holds private keys and files are created with the default
permissions of the process: restrict who can read the directory.
Removing {domain}/certificate.pem makes the manager ask for a new certificate
at the next start. A stored certificate which cannot be parsed, or which is not
for the stored key, is replaced. A key file which cannot be read is left alone:
this is reported as a failure, for you to sort out.
settings
defaultAutoHttps gives an AutoHttps record to update:
let cfg = defaultAutoHttps letsencryptv2 ("example.dicioccio.fr" :| ["www.example.dicioccio.fr"]) contacts "state"
runAutoHttps
cfg
{ httpsSettings = setPort 8443 defaultSettings
, managerSettings = cfg.managerSettings{renewAt = renewBefore (20 * 86400), onEvent = myLogger}
}
application
managerSettings: domains, state directory, renewal and retry times, key
generation (generateKey, RSA-4096 by default), event handler;
acmeSettings: ACME server, path of the account key, contacts, how long an
order is polled;
challengeMethod: Http01 (the default) or TlsAlpn01;
serveHttp: whether to run the HTTP server (True by default);
httpSettings, httpsSettings: the warp settings of both servers;
tweakTls: changes the warp-tls settings (leave the server name indication
hook alone, it is the one reading the certificates). The settings are then
wrapped with withTlsAlpn01;
httpApplication: what the HTTP server runs besides answering challenges.
With several domains, each one has its own certificate, presented to clients
asking for this name (SNI). The certificate of the first domain also is the
default one.
running the parts yourself
runAutoHttps is a dozen lines assembling parts that you can use directly,
e.g. when the HTTP server on port 80 is not a warp server of this program, or
to keep certificates fresh for something else than warp:
challenges <- newChallengeStore
certificates <- newCertificateStore
Right manager <-
newCertificateManager
(defaultManagerSettings ("example.dicioccio.fr" :| []) "state")
(acmeIssuer (defaultAcmeSettings letsencryptv2 "state/account.jwk" contacts) challenges)
certificates
forkIO $ run 80 (http01Middleware challenges (redirectToHttps 443))
forkIO $ runTLS (liveTlsSettings certificates) (setPort 443 defaultSettings) application
runCertificateManager manager
With TLS-ALPN-01 challenges, use a TlsAlpn01Store instead of the
ChallengeStore, and no HTTP server:
challenges <- newTlsAlpn01Store
-- ...
(acmeTlsAlpn01Issuer (defaultAcmeSettings letsencryptv2 "state/account.jwk" contacts) challenges)
-- ...
forkIO $ runTLS (withTlsAlpn01 challenges (liveTlsSettings certificates)) (setPort 443 defaultSettings) application
checkCertificates is a single pass of runCertificateManager, and returns
when the next pass is due. An Issuer is a function from a domain and a key to
a certificate chain: acmeIssuer is the ACME one, the tests use one making
self-signed certificates.
example
example/Main.hs:
cabal build -fexample acme-not-a-joke-warp-example
sudo $(cabal list-bin -fexample acme-not-a-joke-warp-example) example.dicioccio.fr certmaster@dicioccio.fr ./state
Add tls-alpn-01 after the state directory to use TLS-ALPN-01 challenges and
no port 80. It uses the staging environment of Let's Encrypt.
limits
- HTTP-01 or TLS-ALPN-01 only: no wildcard certificates (they need DNS-01).
- TLS-ALPN-01 challenges are answered over TLS 1.3 only.
- One certificate per domain rather than one certificate for several names
(the dance of
acme-not-a-joke validates a single authorization per order).
- Certificate keys are RSA, and the key is reused across renewals.
- The names in a stored certificate are not checked against the domain of its
directory.
- No OCSP stapling, no revocation, no on-demand certificates for names which
are not listed.
- One process per state directory: nothing coordinates several instances
behind a load balancer.