Skip to content

Instantly share code, notes, and snippets.

@mmalone

mmalone/acme.go Secret

Last active April 3, 2024 22:36
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mmalone/abce3c30df96972ed47f3298543be345 to your computer and use it in GitHub Desktop.
Save mmalone/abce3c30df96972ed47f3298543be345 to your computer and use it in GitHub Desktop.
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"sync"
"time"
"github.com/go-acme/lego/certcrypto"
"github.com/go-acme/lego/certificate"
"github.com/go-acme/lego/challenge/http01"
"github.com/go-acme/lego/lego"
"github.com/go-acme/lego/registration"
"golang.org/x/net/http2"
)
const (
// The domain name for which we'll be getting a certificate
domain = "bar.internal"
// The email address to use during ACME registration
acmeEmail = "you@yours.com"
// The ACME directory URL for your ACME server
acmeDirectoryURL = "https://acme.internal/acme/acme/directory"
// The root certificate for the CA that issued the ACME server's
// certificate.
rootCertificate = "/home/mmalone/.step/certs/root_ca.crt"
// How frequently we should check whether our cert needs renewal
tickFrequency = 15 * time.Second
// The listen address for our HTTPS server
listenAddr = ":5443"
)
// loadRootCertPool builds a trust store (cert pool) containing our CA's root
// certificate.
func loadRootCertPool(rootCert string) (*x509.CertPool, error) {
root, err := ioutil.ReadFile(rootCert)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if ok := pool.AppendCertsFromPEM(root); !ok {
return nil, errors.New("Missing or invalid root certificate")
}
return pool, nil
}
// getHTTPSClient gets an HTTPS client configured to trust our CA's root
// certificate.
func getHTTPSClient(rootCert string) (*http.Client, error) {
pool, err := loadRootCertPool(rootCert)
if err != nil {
return nil, err
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
RootCAs: pool,
},
}
if err := http2.ConfigureTransport(tr); err != nil {
return nil, errors.New("Error configuring transport")
}
return &http.Client{
Transport: tr,
}, nil
}
// LegoUser implements registration.User, required by lego.
type LegoUser struct {
email string
registration *registration.Resource
key crypto.PrivateKey
}
func (l *LegoUser) GetEmail() string {
return l.email
}
func (l *LegoUser) GetRegistration() *registration.Resource {
return l.registration
}
func (l *LegoUser) GetPrivateKey() crypto.PrivateKey {
return l.key
}
// Uses techniques from https://diogomonica.com/2017/01/11/hitless-tls-certificate-rotation-in-go/
// to automatically rotate certificates when they're renewed.
// ACMECertManager manages ACME certificate renewals and makes it easy to use
// certificates with the tls package.`
type ACMECertManager struct {
sync.RWMutex
acmeClient *lego.Client
certificate *tls.Certificate
domains []string
leaf *x509.Certificate
resource *certificate.Resource
}
// NewACMECertManager configures an ACME client, creates & registers a new ACME
// user. After creating a client you must call ObtainCertificate and
// RenewCertificate yourself.
func NewACMECertManager(domains []string, email, rootCert, caDirURL string) (*ACMECertManager, error) {
// Create a new ACME user with a new key.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
user := &LegoUser{
email: email,
key: key,
}
// Get an HTTPS client configured to trust our root certificate.
httpClient, err := getHTTPSClient(rootCert)
if err != nil {
return nil, err
}
// Create a configuration using our HTTPS client, ACME server, user details.
config := &lego.Config{
CADirURL: caDirURL,
User: user,
HTTPClient: httpClient,
Certificate: lego.CertificateConfig{
KeyType: certcrypto.RSA2048,
Timeout: 30 * time.Second,
},
}
// Create an ACME client and configure use of `http-01` challenge
acmeClient, err := lego.NewClient(config)
if err != nil {
return nil, err
}
err = acmeClient.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80"))
if err != nil {
log.Fatal(err)
}
// Register our ACME user
registration, err := acmeClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, err
}
user.registration = registration
return &ACMECertManager{
acmeClient: acmeClient,
domains: domains,
}, nil
}
// ObtainCertificate gets a new certificate using ACME. Not thread safe.
func (a *ACMECertManager) ObtainCertificate() error {
request := certificate.ObtainRequest{
Domains: a.domains,
Bundle: true,
}
resource, err := a.acmeClient.Certificate.Obtain(request)
if err != nil {
return err
}
return a.switchCertificate(resource)
}
// RenewCertificate renews an existing certificate using ACME. Not thread safe.
func (a *ACMECertManager) RenewCertificate() error {
resource, err := a.acmeClient.Certificate.Renew(*a.resource, true, false)
if err != nil {
return err
}
return a.switchCertificate(resource)
}
// GetCertificate locks around returning a tls.Certificate; use as tls.Config.GetCertificate.
func (a *ACMECertManager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
a.RLock()
defer a.RUnlock()
return a.certificate, nil
}
// GetLeaf returns the currently valid leaf x509.Certificate
func (a *ACMECertManager) GetLeaf() x509.Certificate {
a.RLock()
defer a.RUnlock()
return *a.leaf
}
// NextRenewal returns when the certificate will be 2/3 of the way to expiration.
func (a *ACMECertManager) NextRenewal() time.Time {
leaf := a.GetLeaf()
lifetime := leaf.NotAfter.Sub(leaf.NotBefore).Seconds()
return leaf.NotBefore.Add(time.Duration(lifetime*2/3) * time.Second)
}
// NeedsRenewal returns true if the certificate's age is more than 2/3 it's
// lifetime.
func (a *ACMECertManager) NeedsRenewal() bool {
return time.Now().After(a.NextRenewal())
}
func (a *ACMECertManager) switchCertificate(newResource *certificate.Resource) error {
// The certificate.Resource represents our certificate as a PEM-encoded
// bundle of bytes. Let's process it. First create a tls.Certificate
// for use with the tls package.
crt, err := tls.X509KeyPair(newResource.Certificate, newResource.PrivateKey)
if err != nil {
return err
}
// Now create an x509.Certificate so we can figure out when the cert
// expires. Note that the first certificate in the bundle is the leaf.
// Go ahead and set crt.Leaf as an optimization.
leaf, err := x509.ParseCertificate(crt.Certificate[0])
if err != nil {
return err
}
crt.Leaf = leaf
a.Lock()
defer a.Unlock()
a.resource = newResource
a.certificate = &crt
a.leaf = leaf
return nil
}
func main() {
// Let's create a little web server that responds to TLS or mutually
// authenticated TLS connections. If we get a mutual TLS connection
// we'll respond with a friendly greeting using the client's name.
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
fmt.Fprintf(w, "Hello, TLS!\n")
} else {
name := r.TLS.PeerCertificates[0].Subject.CommonName
fmt.Fprintf(w, "Hello, %s!\n", name)
}
})
// Create a trust pool with our CA's root certificate; used to validate
// client certificates.
roots, err := loadRootCertPool(rootCertificate)
if err != nil {
log.Fatal(err)
}
// Configure our ACME cert manager and get a certificate using ACME!
acm, err := NewACMECertManager([]string{domain}, acmeEmail, rootCertificate, acmeDirectoryURL)
if err != nil {
log.Fatal("Error creating ACMECertManager", err)
}
err = acm.ObtainCertificate()
if err != nil {
log.Fatal("Error loading certificate and key", err)
}
// Create a TLS configuration for our HTTPS server. The fancy bits here
// are commented.
cfg := &tls.Config{
// This makes client authentication optional. Switch to
// tls.RequireAndVerifyClientCert to require authentication.
ClientAuth: tls.VerifyClientCertIfGiven,
// We'll be trusting client certificates issued by our CA.
ClientCAs: roots,
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256},
PreferServerCipherSuites: true,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
// Dynamically load certificate from ACMECertManager with every
// connection, so renewals work.
GetCertificate: acm.GetCertificate,
}
// Make a server!
srv := &http.Server{
Addr: listenAddr,
Handler: mux,
TLSConfig: cfg,
}
// Schedule periodic certificate renewal (do ACME again periodically).
// We'll tick every timeFrequency but only renew if the certificate
// is approaching expiration. That'll give us some resilience to CA
// downtime.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(tickFrequency)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if acm.NeedsRenewal() {
fmt.Println("Renewing certificate")
err := acm.RenewCertificate()
if err != nil {
log.Println("Error loading certificate and key", err)
} else {
leaf := acm.GetLeaf()
fmt.Printf("Renewed certificate: %s [%s - %s]\n", leaf.Subject, leaf.NotBefore, leaf.NotAfter)
fmt.Printf("Next renewal at %s (%s)\n", acm.NextRenewal(), acm.NextRenewal().Sub(time.Now()))
}
} else {
fmt.Printf("Waiting to renew at %s (%s)\n", acm.NextRenewal(), acm.NextRenewal().Sub(time.Now()))
}
case <-done:
return
}
}
}()
defer close(done)
log.Printf("Listening on %s\n", listenAddr)
// Start serving HTTPS!
err = srv.ListenAndServeTLS("", "")
if err != nil {
log.Fatal("ListenAndServerTLS: ", err)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment