Skip to content

Instantly share code, notes, and snippets.

@dradtke
Last active June 28, 2019 22:01
Show Gist options
  • Save dradtke/35f115f88ba25db697ca9dea858f1504 to your computer and use it in GitHub Desktop.
Save dradtke/35f115f88ba25db697ca9dea858f1504 to your computer and use it in GitHub Desktop.
Go program that periodically renews SSL certificates using Let's Encrypt and updates a Lindode NodeBalancer
package main
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/linode/linodego"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/oauth2"
)
const (
RenewalDuration = 24 * time.Hour
Host = "damienradtke.com"
BalancerName = "damienradtkecom"
LinodeToken = "..."
)
var manager = autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(os.Getenv("NOMAD_SECRETS_DIR")),
HostPolicy: autocert.HostWhitelist(Host, "www."+Host),
Email: "[email protected]",
}
func main() {
go periodicallyRenewAndSave()
var port = os.Getenv("NOMAD_PORT_http")
log.Println("listening on port " + port)
if err := http.ListenAndServe(":"+port, manager.HTTPHandler(http.HandlerFunc(fallback))); err != nil {
log.Fatal(err)
}
}
func periodicallyRenewAndSave() {
for range time.NewTicker(RenewalDuration).C {
renewAndSave(context.Background())
}
}
// renewAndSave gets called every so often based on the value of
// RenewalDuration. It coordinates renewing the certificate and updating the
// NodeBalancer with the result.
func renewAndSave(ctx context.Context) {
log.Println("renewing certificate")
cert, err := manager.GetCertificate(&tls.ClientHelloInfo{
ServerName: Host,
})
if err != nil {
log.Printf("failed to renew certificate: %s", err)
return
}
certText, err := getCertText(cert)
if err != nil {
log.Printf("failed to marshal certificate: %s", err)
return
}
keyText, err := getKeyText(cert)
if err != nil {
log.Printf("failed to marshal key: %s", err)
return
}
log.Println("saving certificate to nodebalancer config")
if err = save(ctx, certText, keyText); err != nil {
log.Printf("failed to save certificate: %s", err)
return
}
log.Println("certificate updated!")
}
// getCertText encodes the public chain in the provided certificate in standard
// PEM format, which is how Linode expects to receive it.
func getCertText(cert *tls.Certificate) (string, error) {
var builder strings.Builder
for _, part := range cert.Certificate {
if err := pem.Encode(&builder, &pem.Block{
Type: "CERTIFICATE",
Bytes: part,
}); err != nil {
return "", err
}
}
return builder.String(), nil
}
// getKeyText encodes the private key in the provided certificate in standard
// PEM format, which is how Linode expects to receive it.
func getKeyText(cert *tls.Certificate) (string, error) {
switch t := cert.PrivateKey.(type) {
case *rsa.PrivateKey:
v := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(t),
})
return string(v), nil
default:
return "", fmt.Errorf("unknown private key type: %T", cert.PrivateKey)
}
}
// save updates the 443 NodeBalancer config with the provided certificate and
// key text, or it creates a new one if it doesn't already exist.
func save(ctx context.Context, certText, keyText string) error {
linode := createLinodeClient(LinodeToken)
balancer, err := findNodeBalancer(ctx, linode, BalancerName)
if err != nil {
return errors.New("failed to find node balancer: " + err.Error())
} else if balancer == nil {
return errors.New("failed to find node balancer: not found")
}
config, err := findNodeBalancerConfig(ctx, linode, balancer)
if err != nil {
return errors.New("failed to find node balancer config: " + err.Error())
}
if config == nil {
log.Println("creating new node balancer config")
if _, err := linode.CreateNodeBalancerConfig(ctx, balancer.ID, linodego.NodeBalancerConfigCreateOptions{
Port: 443,
Protocol: linodego.ProtocolHTTPS,
Algorithm: linodego.AlgorithmRoundRobin,
Stickiness: linodego.StickinessNone,
Check: linodego.CheckNone,
SSLCert: certText,
SSLKey: keyText,
}); err != nil {
return errors.New("failed to create new node balancer config: " + err.Error())
}
} else {
if _, err = linode.UpdateNodeBalancerConfig(ctx, balancer.ID, config.ID, linodego.NodeBalancerConfigUpdateOptions{
SSLCert: certText,
SSLKey: keyText,
}); err != nil {
return errors.New("failed to update node balancer config: " + err.Error())
}
}
return nil
}
func findNodeBalancer(ctx context.Context, linode linodego.Client, name string) (*linodego.NodeBalancer, error) {
balancers, err := linode.ListNodeBalancers(ctx, nil)
if err != nil {
return nil, err
}
for _, balancer := range balancers {
if *balancer.Label == name {
return &balancer, nil
}
}
return nil, nil
}
func findNodeBalancerConfig(ctx context.Context, linode linodego.Client, balancer linodego.NodeBalancer) (*linodego.NodeBalancerConfig, error) {
configs, err := linode.ListNodeBalancerConfigs(ctx, balancer.ID, nil)
if err != nil {
return nil, err
}
for _, config := range configs {
if config.Port == 443 {
return &config, nil
}
}
return nil, nil
}
func createLinodeClient(token string) linodego.Client {
oauth2Client := &http.Client{
Transport: &oauth2.Transport{
Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}),
},
}
return linodego.NewClient(oauth2Client)
}
// fallback defines the HTTP handler executed when the web server receives a
// request other than an HTTP-01 challenge. In this case, we can assume that it
// was a user, so we redirect to the HTTPS URL for the site.
func fallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://damienradtke.com/", http.StatusFound)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment