Skip to content

Commit 90507a8

Browse files
committed
Add support for LetsEncrypt via domain annotation
* 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
1 parent bc3ec8f commit 90507a8

File tree

5 files changed

+1502
-105
lines changed

5 files changed

+1502
-105
lines changed

cloud-controller-manager/do/certificates.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,212 @@ limitations under the License.
1616

1717
package do
1818

19+
import (
20+
"context"
21+
"fmt"
22+
"net/http"
23+
"strings"
24+
25+
"github.com/digitalocean/godo"
26+
v1 "k8s.io/api/core/v1"
27+
"k8s.io/klog"
28+
)
29+
1930
const (
2031
// DO Certificate types
2132
certTypeLetsEncrypt = "lets_encrypt"
2233
certTypeCustom = "custom"
34+
35+
// Certificate constants
36+
certPrefix = "do-ccm-"
2337
)
38+
39+
// ensureDomain checks to see if the service contains the annDODomain annotation
40+
// and if it does it verifies the domain exists on the users account
41+
func (l *loadBalancers) ensureDomain(ctx context.Context, service *v1.Service) (*domain, error) {
42+
domain, err := getDomain(service)
43+
if err != nil {
44+
return domain, err
45+
}
46+
47+
if domain == nil {
48+
return nil, nil
49+
}
50+
51+
klog.V(2).Infof("Looking up root domain specified in service: %s", domain.root)
52+
_, _, err = l.resources.gclient.Domains.Get(ctx, domain.root)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to retrieve root domain %s: %s", domain.root, err)
55+
}
56+
57+
return domain, nil
58+
}
59+
60+
func (l *loadBalancers) validateCertificateExistence(ctx context.Context, certificateID string) (*godo.Certificate, error) {
61+
if certificateID == "" {
62+
return nil, nil
63+
}
64+
65+
certificate, resp, err := l.resources.gclient.Certificates.Get(ctx, certificateID)
66+
if err != nil && resp.StatusCode != http.StatusNotFound {
67+
return nil, fmt.Errorf("failed to fetch certificate: %s", err)
68+
}
69+
70+
return certificate, nil
71+
}
72+
73+
// validateServiceCertificate ensures the certificate specified in the service annotation
74+
// still exists. If it does not, then the annotation is cleared from the service.
75+
func (l *loadBalancers) validateServiceCertificate(ctx context.Context, service *v1.Service) (*godo.Certificate, error) {
76+
certificateID := getCertificateID(service)
77+
klog.V(2).Infof("Looking up certificate for service %s/%s by ID %s", service.Namespace, service.Name, certificateID)
78+
certificate, err := l.validateCertificateExistence(ctx, certificateID)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
if certificate == nil {
84+
updateServiceAnnotation(service, annDOCertificateID, "")
85+
}
86+
87+
return certificate, nil
88+
}
89+
90+
func (l *loadBalancers) ensureCertificateForDomain(ctx context.Context, serviceCertificate *godo.Certificate, domain *domain) (*godo.Certificate, error) {
91+
if serviceCertificate != nil && isValidCertificateForDomain(serviceCertificate, domain) {
92+
return serviceCertificate, nil
93+
}
94+
95+
serviceCertificate, err := l.findCertificateForDomain(ctx, domain)
96+
if err != nil {
97+
return nil, err
98+
}
99+
100+
if serviceCertificate == nil {
101+
serviceCertificate, err = l.generateCertificateForDomain(ctx, domain)
102+
if err != nil {
103+
return nil, err
104+
}
105+
}
106+
107+
return serviceCertificate, nil
108+
}
109+
110+
func isValidCertificateForDomain(certificate *godo.Certificate, domain *domain) bool {
111+
for _, dnsName := range certificate.DNSNames {
112+
if dnsName == domain.full {
113+
// we found matching certificate, break out of ensureCertificate
114+
return true
115+
}
116+
}
117+
118+
return false
119+
}
120+
121+
func (l *loadBalancers) findCertificateForDomain(ctx context.Context, domain *domain) (*godo.Certificate, error) {
122+
certificates, _, err := l.resources.gclient.Certificates.List(ctx, &godo.ListOptions{})
123+
if err != nil {
124+
return nil, fmt.Errorf("Failed to list certificates: %s", err)
125+
}
126+
127+
var certificate *godo.Certificate
128+
129+
for _, c := range certificates {
130+
if isValidCertificateForDomain(&c, domain) {
131+
certificate = &c
132+
break
133+
}
134+
}
135+
136+
return certificate, nil
137+
}
138+
139+
func (l *loadBalancers) generateCertificateForDomain(ctx context.Context, domain *domain) (*godo.Certificate, error) {
140+
certName := getCertificateName(domain.full)
141+
dnsNames := []string{domain.root}
142+
143+
if domain.sub != "" {
144+
dnsNames = append(dnsNames, domain.full)
145+
}
146+
147+
certificateReq := &godo.CertificateRequest{
148+
Name: certName,
149+
DNSNames: dnsNames,
150+
Type: certTypeLetsEncrypt,
151+
}
152+
153+
klog.V(2).Infof("Generating new certificate for domain: %s", domain.full)
154+
certificate, _, err := l.resources.gclient.Certificates.Create(ctx, certificateReq)
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to create certificate: %s", err)
157+
}
158+
159+
return certificate, nil
160+
}
161+
162+
func findARecordForNameAndIP(records []godo.DomainRecord, name string, ip string) (*godo.DomainRecord, error) {
163+
var record *godo.DomainRecord
164+
165+
for _, r := range records {
166+
if r.Type != "A" || r.Name != name {
167+
continue
168+
}
169+
170+
if r.Data != ip {
171+
return nil, fmt.Errorf("the A record(%s) is already in use with another IP(%s)", name, r.Data)
172+
}
173+
174+
record = &r
175+
break
176+
}
177+
178+
return record, nil
179+
}
180+
181+
// ensureDomainARecords ensures that if the service has a domain annotation,
182+
// the domain has an A record for the full subdomain pointing to the loadbalancer
183+
func (l *loadBalancers) ensureDomainARecords(ctx context.Context, domain *domain, lb *godo.LoadBalancer) error {
184+
records, _, err := l.resources.gclient.Domains.Records(ctx, domain.root, &godo.ListOptions{})
185+
if err != nil {
186+
return fmt.Errorf("failed to fetch records for domain(%s): %s", domain.root, err)
187+
}
188+
189+
err = l.ensureDomainARecord(ctx, records, domain.root, "@", lb.IP)
190+
if err != nil {
191+
return err
192+
}
193+
194+
err = l.ensureDomainARecord(ctx, records, domain.root, domain.sub, lb.IP)
195+
if err != nil {
196+
return err
197+
}
198+
199+
return nil
200+
}
201+
202+
func (l *loadBalancers) ensureDomainARecord(ctx context.Context, records []godo.DomainRecord, domain string, name string, ip string) error {
203+
record, err := findARecordForNameAndIP(records, name, ip)
204+
if err != nil {
205+
return err
206+
}
207+
208+
if record == nil {
209+
_, _, err = l.resources.gclient.Domains.CreateRecord(ctx, domain, &godo.DomainRecordEditRequest{
210+
Type: "A",
211+
Name: name,
212+
Data: ip,
213+
TTL: defaultDomainRecordTTL,
214+
})
215+
if err != nil {
216+
return err
217+
}
218+
}
219+
220+
return nil
221+
}
222+
223+
// getCertificateName returns a prefixed certificate so we know to cleanup
224+
// certificate when a loadbalancer for the given domain is deleted
225+
func getCertificateName(fullDomain string) string {
226+
return fmt.Sprintf("%s%s", certPrefix, strings.ReplaceAll(fullDomain, ".", "-"))
227+
}

0 commit comments

Comments
 (0)