simonmittag/jabba

View on GitHub
acmehandler.go

Summary

Maintainability
A
2 hrs
Test Coverage
C
78%
package j8a

import (
    "bytes"
    "crypto"
    "crypto/ecdsa"
    "crypto/elliptic"
    "crypto/rand"
    "crypto/sha256"
    "errors"
    "fmt"
    "io/fs"
    "io/ioutil"
    "net/http"
    "os"
    "path/filepath"
    "strings"

    "github.com/rs/zerolog/log"
    "github.com/simonmittag/lego/v4/certcrypto"
    "github.com/simonmittag/lego/v4/certificate"
    "github.com/simonmittag/lego/v4/challenge/http01"
    "github.com/simonmittag/lego/v4/lego"
    "github.com/simonmittag/lego/v4/registration"
)

const acmeChallenge = "/.well-known/acme-challenge/"

type acmeProvider struct {
    endpoint string
    friendlyName string
    tosURL string
}

var acmeProviders = map[string]acmeProvider {
    "letsencrypt": acmeProvider{"https://acme-v02.api.letsencrypt.org/directory", "LetsEncrypt", "https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement"},
    "let'sencrypt": acmeProvider{"https://acme-v02.api.letsencrypt.org/directory", "LetsEncrypt", "https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement"},
    "letsencryptstaging": acmeProvider{"https://acme-staging-v02.api.letsencrypt.org/directory", "LetsEncrypt Staging", "https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement"},
    "let'sencryptstaging": acmeProvider{"https://acme-staging-v02.api.letsencrypt.org/directory", "LetsEncrypt Staging", "https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement"},
}

type AcmeHandler struct {
    Active   map[string]bool
    Domains  map[string]string
    KeyAuths map[string][]byte
}

func NewAcmeHandler() *AcmeHandler {
    return &AcmeHandler{
        Active:   make(map[string]bool),
        Domains:  make(map[string]string),
        KeyAuths: make(map[string][]byte),
    }
}

func (a *AcmeHandler) Present(domain, token, keyAuth string) error {
    a.Active[token] = true
    a.Domains[token] = domain
    a.KeyAuths[token] = []byte(keyAuth)

    log.Info().Msgf("ACME handler for domain %s activated, ready to serve challenge response for token %s.", domain, token)
    return nil
}

func (a *AcmeHandler) CleanUp(domain, token, keyAuth string) error {
    delete(a.Active, token)
    delete(a.Domains, token)
    delete(a.KeyAuths, token)

    log.Info().Msgf("ACME handler for domain %s deactivated.", domain)
    return nil
}

func (a *AcmeHandler) isActive() bool {
    var c = false
    for _, v := range a.Active {
        c = c || v
    }
    return c
}

const acmeEvent = "responded to remote ACME challenge path %s, with %s for domain %s"

func acmeHandler(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if r1 := recover(); r1 != nil {
            log.Warn().Msgf("unsuccessful response to remote ACME challenge, URI %s, cause: %v", r.RequestURI, r1)
        }
    }()

    tokens := strings.Split(r.RequestURI, "/")
    token := tokens[len(tokens)-1]

    a := Runner.AcmeHandler
    path := http01.ChallengePath(token)
    w.WriteHeader(200)
    w.Write([]byte(a.KeyAuths[token]))
    log.Info().Msgf(acmeEvent, path, a.KeyAuths[token], a.Domains[token])
}

type AcmeUser struct {
    Email        string
    Registration *registration.Resource
    key          crypto.PrivateKey
}

func (u *AcmeUser) GetEmail() string {
    return u.Email
}
func (u AcmeUser) GetRegistration() *registration.Resource {
    return u.Registration
}
func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
    return u.key
}

const acmeKeyFile = "tls.pk"
const acmeCertFile = "tls.cert"
const acmeHashFile = "confighash"

func (runtime *Runtime) loadAcmeCertAndKeyFromCache(provider string) error {
    var e error

    if !runtime.cacheDirIsActive() {
        return errors.New("cache directory not active, cannot load TLS from cache")
    }

    keyFile := filepath.FromSlash(Runner.cacheDir + "/" + provider + "/" + acmeKeyFile)
    key, e1 := ioutil.ReadFile(keyFile)
    if e1 != nil {
        return e1
    }

    certFile := filepath.FromSlash(Runner.cacheDir + "/" + provider + "/" + acmeCertFile)
    cert, e2 := ioutil.ReadFile(certFile)
    if e2 != nil {
        return e2
    }

    hashFile := filepath.FromSlash(Runner.cacheDir + "/" + provider + "/" + acmeHashFile)
    hash, e3 := ioutil.ReadFile(hashFile)
    if e3 != nil {
        return e3
    }

    clearCache := func(msg string) {
        os.Remove(keyFile)
        os.Remove(certFile)
        os.Remove(hashFile)
        log.Info().Msg(msg)
    }

    if c := bytes.Compare(hash, runtime.acmeProviderHashAsBytes()); c != 0 {
        msg := fmt.Sprintf("active TLS configuration does not match cached cert and key, clearing cache for ACME provider %s", provider)
        clearCache(msg)
        return errors.New(msg)
    }

    if _, e3 := checkFullCertChainFromBytes(cert, key); e3 == nil {
        runtime.Connection.Downstream.Tls.Key = string(key)
        runtime.Connection.Downstream.Tls.Cert = string(cert)
        log.Info().Msgf("TLS cert and key for ACME provider %s loaded from cache", provider)
    } else {
        //if delete doesn't work ignore this it may already be gone (partially).
        msg := fmt.Sprintf("unable to load data, clearing TLS cache for ACME provider %s", provider)
        clearCache(msg)
        return errors.New(msg)
    }

    return e
}

const acmeRwx fs.FileMode = 0700

func (runtime *Runtime) cacheAcmeCertAndKey(provider string) error {
    var e error

    if !runtime.cacheDirIsActive() {
        return errors.New("cache directory not active, cannot cache keys")
    } else {
        //it doesn't matter if this fails because dir already exists
        os.Mkdir(runtime.cacheDir+"/"+provider, acmeRwx)
    }

    e1 := ioutil.WriteFile(
        filepath.FromSlash(Runner.cacheDir+"/"+provider+"/"+acmeKeyFile),
        []byte(runtime.Connection.Downstream.Tls.Key),
        acmeRwx)
    if e1 != nil {
        return e1
    }

    e2 := ioutil.WriteFile(
        filepath.FromSlash(Runner.cacheDir+"/"+provider+"/"+acmeCertFile),
        []byte(runtime.Connection.Downstream.Tls.Cert),
        acmeRwx)
    if e2 != nil {
        return e2
    }

    e3 := ioutil.WriteFile(
        filepath.FromSlash(Runner.cacheDir+"/"+provider+"/"+acmeHashFile),
        runtime.acmeProviderHashAsBytes(),
        acmeRwx)
    if e3 != nil {
        return e3
    }

    log.Info().Msgf("stored TLS cert and key for ACME provider %s in cache", provider)
    return e
}

func asSha256(o interface{}) string {
    h := sha256.New()
    h.Write([]byte(fmt.Sprintf("%v", o)))

    return fmt.Sprintf("%x", h.Sum(nil))
}

func (runtime *Runtime) acmeProviderHashAsBytes() []byte {
    return []byte(asSha256(runtime.Connection.Downstream.Tls.Acme))
}

func (runtime *Runtime) fetchAcmeCertAndKey(url string) error {
    var e error

    defer func() {
        if r := recover(); r != nil {
            msg := fmt.Sprintf("TLS ACME certificate not fetched, cause: %s", r)
            log.Warn().Msg(msg)
            e = errors.New(msg)
        }
    }()

    var pk *ecdsa.PrivateKey

    pk, e = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    if e != nil {
        return e
    }

    myUser := AcmeUser{
        Email: runtime.Connection.Downstream.Tls.Acme.Email,
        key:   pk,
    }

    config := lego.NewConfig(&myUser)
    config.Certificate.KeyType = certcrypto.RSA2048
    var client *lego.Client
    client, e = lego.NewClient(config)
    if e != nil {
        return e
    }

    e = client.Challenge.SetHTTP01Provider(runtime.AcmeHandler)
    if e != nil {
        return e
    }

    //we always register because it's safer than to cache credentials
    myUser.Registration, e = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
    if e != nil {
        return e
    }

    request := certificate.ObtainRequest{
        Domains: runtime.Connection.Downstream.Tls.Acme.Domains,
        Bundle:  true,
    }

    var c *certificate.Resource
    c, e = client.Certificate.Obtain(request)
    if e != nil {
        log.Warn().Msgf("ACME certificate from %s unsuccessful, cause %v", url, e)
        return e
    }

    runtime.Connection.Downstream.Tls.Cert = string(c.Certificate)
    runtime.Connection.Downstream.Tls.Key = string(c.PrivateKey)

    log.Info().Msgf("ACME certificate successfully fetched from %s", url)

    return e
}