Skip to content

Commit

Permalink
Add support for LetsEncrypt via domain annotation
Browse files Browse the repository at this point in the history
* Expects root domain to already be created and validated on
DigitalOcean (DO is not a registrar so we assume user has preconfigured
domain)
* Add domain annotation to specify either the root domain or a subdomain
of your choosing to the LoadBalancer service
* Automatically find or generate certificate, and attach to
LoadBalancer
* Automatically generate A-record for your subdomain to point to the
LoadBalancer
  • Loading branch information
nicktate committed Nov 20, 2019
1 parent bc3ec8f commit 1c3ef6b
Show file tree
Hide file tree
Showing 5 changed files with 1,502 additions and 105 deletions.
204 changes: 204 additions & 0 deletions cloud-controller-manager/do/certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,212 @@ limitations under the License.

package do

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/digitalocean/godo"
v1 "k8s.io/api/core/v1"
"k8s.io/klog"
)

const (
// DO Certificate types
certTypeLetsEncrypt = "lets_encrypt"
certTypeCustom = "custom"

// Certificate constants
certPrefix = "do-ccm-"
)

// ensureDomain checks to see if the service contains the annDODomain annotation
// and if it does it verifies the domain exists on the users account
func (l *loadBalancers) ensureDomain(ctx context.Context, service *v1.Service) (*domain, error) {
domain, err := getDomain(service)
if err != nil {
return domain, err
}

if domain == nil {
return nil, nil
}

klog.V(2).Infof("Looking up root domain specified in service: %s", domain.root)
_, _, err = l.resources.gclient.Domains.Get(ctx, domain.root)
if err != nil {
return nil, fmt.Errorf("failed to retrieve root domain %s: %s", domain.root, err)
}

return domain, nil
}

func (l *loadBalancers) validateCertificateExistence(ctx context.Context, certificateID string) (*godo.Certificate, error) {
if certificateID == "" {
return nil, nil
}

certificate, resp, err := l.resources.gclient.Certificates.Get(ctx, certificateID)
if err != nil && resp.StatusCode != http.StatusNotFound {
return nil, fmt.Errorf("failed to fetch certificate: %s", err)
}

return certificate, nil
}

// validateServiceCertificate ensures the certificate specified in the service annotation
// still exists. If it does not, then the annotation is cleared from the service.
func (l *loadBalancers) validateServiceCertificate(ctx context.Context, service *v1.Service) (*godo.Certificate, error) {
certificateID := getCertificateID(service)
klog.V(2).Infof("Looking up certificate for service %s/%s by ID %s", service.Namespace, service.Name, certificateID)
certificate, err := l.validateCertificateExistence(ctx, certificateID)
if err != nil {
return nil, err
}

if certificate == nil {
updateServiceAnnotation(service, annDOCertificateID, "")
}

return certificate, nil
}

func (l *loadBalancers) ensureCertificateForDomain(ctx context.Context, serviceCertificate *godo.Certificate, domain *domain) (*godo.Certificate, error) {
if serviceCertificate != nil && isValidCertificateForDomain(serviceCertificate, domain) {
return serviceCertificate, nil
}

serviceCertificate, err := l.findCertificateForDomain(ctx, domain)
if err != nil {
return nil, err
}

if serviceCertificate == nil {
serviceCertificate, err = l.generateCertificateForDomain(ctx, domain)
if err != nil {
return nil, err
}
}

return serviceCertificate, nil
}

func isValidCertificateForDomain(certificate *godo.Certificate, domain *domain) bool {
for _, dnsName := range certificate.DNSNames {
if dnsName == domain.full {
// we found matching certificate, break out of ensureCertificate
return true
}
}

return false
}

func (l *loadBalancers) findCertificateForDomain(ctx context.Context, domain *domain) (*godo.Certificate, error) {
certificates, _, err := l.resources.gclient.Certificates.List(ctx, &godo.ListOptions{})
if err != nil {
return nil, fmt.Errorf("Failed to list certificates: %s", err)
}

var certificate *godo.Certificate

for _, c := range certificates {
if isValidCertificateForDomain(&c, domain) {
certificate = &c
break
}
}

return certificate, nil
}

func (l *loadBalancers) generateCertificateForDomain(ctx context.Context, domain *domain) (*godo.Certificate, error) {
certName := getCertificateName(domain.full)
dnsNames := []string{domain.root}

if domain.sub != "" {
dnsNames = append(dnsNames, domain.full)
}

certificateReq := &godo.CertificateRequest{
Name: certName,
DNSNames: dnsNames,
Type: certTypeLetsEncrypt,
}

klog.V(2).Infof("Generating new certificate for domain: %s", domain.full)
certificate, _, err := l.resources.gclient.Certificates.Create(ctx, certificateReq)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %s", err)
}

return certificate, nil
}

func findARecordForNameAndIP(records []godo.DomainRecord, name string, ip string) (*godo.DomainRecord, error) {
var record *godo.DomainRecord

for _, r := range records {
if r.Type != "A" || r.Name != name {
continue
}

if r.Data != ip {
return nil, fmt.Errorf("the A record(%s) is already in use with another IP(%s)", name, r.Data)
}

record = &r
break
}

return record, nil
}

// ensureDomainARecords ensures that if the service has a domain annotation,
// the domain has an A record for the full subdomain pointing to the loadbalancer
func (l *loadBalancers) ensureDomainARecords(ctx context.Context, domain *domain, lb *godo.LoadBalancer) error {
records, _, err := l.resources.gclient.Domains.Records(ctx, domain.root, &godo.ListOptions{})
if err != nil {
return fmt.Errorf("failed to fetch records for domain(%s): %s", domain.root, err)
}

err = l.ensureDomainARecord(ctx, records, domain.root, "@", lb.IP)
if err != nil {
return err
}

err = l.ensureDomainARecord(ctx, records, domain.root, domain.sub, lb.IP)
if err != nil {
return err
}

return nil
}

func (l *loadBalancers) ensureDomainARecord(ctx context.Context, records []godo.DomainRecord, domain string, name string, ip string) error {
record, err := findARecordForNameAndIP(records, name, ip)
if err != nil {
return err
}

if record == nil {
_, _, err = l.resources.gclient.Domains.CreateRecord(ctx, domain, &godo.DomainRecordEditRequest{
Type: "A",
Name: name,
Data: ip,
TTL: defaultDomainRecordTTL,
})
if err != nil {
return err
}
}

return nil
}

// getCertificateName returns a prefixed certificate so we know to cleanup
// certificate when a loadbalancer for the given domain is deleted
func getCertificateName(fullDomain string) string {
return fmt.Sprintf("%s%s", certPrefix, strings.ReplaceAll(fullDomain, ".", "-"))
}
Loading

0 comments on commit 1c3ef6b

Please sign in to comment.