Last active
June 28, 2019 22:01
-
-
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
This file contains hidden or 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 ( | |
"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