-
-
Save mmalone/abce3c30df96972ed47f3298543be345 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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