acme-not-a-joke-warp: automatic HTTPS for warp: obtains, stores, renews and serves ACME certificates

[ bsd3, library, security ] [ Propose Tags ] [ Report a vulnerability ]

runs a wai application over HTTPS with certificates obtained from an ACME provider such as Let's Encrypt (rfc-8555, HTTP-01 challenges, or TLS-ALPN-01 challenges of rfc-8737 which need no port 80), in the spirit of Caddy. A certificate manager obtains the certificates when the server starts, stores them on disk, renews them before they expire and hands them to the running warp-tls server without a restart. Built on the acme-not-a-joke and acme-not-a-joke-wai libraries.


[Skip to Readme]

Flags

Manual Flags

NameDescriptionDefault
example

build the example program (a warp server with automatic HTTPS)

Disabled

Use -f <flag> to enable a flag, or -f -<flag> to disable that flag. More info

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.1.0.0
Change log CHANGELOG.md
Dependencies acme-not-a-joke (>=0.2 && <0.3), acme-not-a-joke-wai (>=0.1 && <0.2), acme-not-a-joke-warp, async (>=2.2.4 && <2.3), base (>=4.19.1 && <5), bytestring (>=0.12.1 && <0.13), containers (>=0.6.8 && <0.8), crypton (>=1.0 && <1.1), crypton-x509 (>=1.8 && <1.9), directory (>=1.3.8 && <1.4), filepath (>=1.4.200 && <1.6), http-types (>=0.12.4 && <0.13), text (>=2.1.1 && <2.2), time (>=1.12.2 && <1.13), time-hourglass (>=0.2.12 && <0.4), tls (>=2.1.3 && <2.5), transformers (>=0.6.1 && <0.7), wai (>=3.2.4 && <3.3), warp (>=3.4 && <3.5), warp-tls (>=3.4.13 && <3.5) [details]
Tested with ghc ==9.8.2 || ==9.10.3
License BSD-3-Clause
Copyright Lucas DiCioccio 2023-2026
Author Lucas DiCioccio
Maintainer lucas@dicioccio.fr
Uploaded by LucasDiCioccio at 2026-10-23T06:57:58Z
Category Security
Home page https://lucasdicioccio.github.io/acme-not-a-joke/
Bug tracker https://github.com/lucasdicioccio/acme-not-a-joke/issues
Source repo head: git clone https://github.com/lucasdicioccio/acme-not-a-joke.git(acme-not-a-joke-warp)
Distributions
Reverse Dependencies 1 direct, 0 indirect [details]
Executables acme-not-a-joke-warp-example
Downloads 3 total (3 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs uploaded by user
Build status unknown [no reports yet]

Readme for acme-not-a-joke-warp-0.1.0.0

[back to package description]

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:

  1. loads the certificate stored by a previous run, and serves it;
  2. 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;
  3. 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.