Skip to content

Commit 0600670

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 d0e9cec commit 0600670

7 files changed

Lines changed: 1997 additions & 168 deletions

File tree

cloud-controller-manager/do/certificates.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,225 @@ 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+
// validateCertificateExistence tests to see if the certificate referenced by the ID exists. If it exists
61+
// the certificate is returned.
62+
func (l *loadBalancers) validateCertificateExistence(ctx context.Context, certificateID string) (*godo.Certificate, error) {
63+
if certificateID == "" {
64+
return nil, nil
65+
}
66+
67+
certificate, resp, err := l.resources.gclient.Certificates.Get(ctx, certificateID)
68+
if err != nil && resp.StatusCode != http.StatusNotFound {
69+
return nil, fmt.Errorf("failed to fetch certificate: %s", err)
70+
}
71+
72+
return certificate, nil
73+
}
74+
75+
// validateServiceCertificate ensures the certificate specified in the service annotation
76+
// still exists. If it does not, then the annotation is cleared from the service.
77+
func (l *loadBalancers) validateServiceCertificate(ctx context.Context, service *v1.Service) (*godo.Certificate, error) {
78+
certificateID := getCertificateID(service)
79+
klog.V(2).Infof("Looking up certificate for service %s/%s by ID %s", service.Namespace, service.Name, certificateID)
80+
certificate, err := l.validateCertificateExistence(ctx, certificateID)
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
if certificate == nil {
86+
updateServiceAnnotation(service, annDOCertificateID, "")
87+
}
88+
89+
return certificate, nil
90+
}
91+
92+
// ensureCertificateForDomain attempts to fetch a valid certificate for the given domain. If it cannot find an existing valid
93+
// certificate, a new certificate is generated for the domain.
94+
func (l *loadBalancers) ensureCertificateForDomain(ctx context.Context, serviceCertificate *godo.Certificate, domain *domain) (*godo.Certificate, error) {
95+
if serviceCertificate != nil && isValidCertificateForDomain(serviceCertificate, domain) {
96+
return serviceCertificate, nil
97+
}
98+
99+
serviceCertificate, err := l.findCertificateForDomain(ctx, domain)
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
if serviceCertificate == nil {
105+
serviceCertificate, err = l.generateCertificateForDomain(ctx, domain)
106+
if err != nil {
107+
return nil, err
108+
}
109+
}
110+
111+
return serviceCertificate, nil
112+
}
113+
114+
// isValidCertificateForDomain verifies that the certificate DNSNames include the given domain
115+
func isValidCertificateForDomain(certificate *godo.Certificate, domain *domain) bool {
116+
for _, dnsName := range certificate.DNSNames {
117+
if dnsName == domain.full {
118+
// we found matching certificate, break out of ensureCertificate
119+
return true
120+
}
121+
}
122+
123+
return false
124+
}
125+
126+
// findCertificateForDomain fetches all certificates from the client and attempts to locate a certificate that is
127+
// valid for the given domain.
128+
func (l *loadBalancers) findCertificateForDomain(ctx context.Context, domain *domain) (*godo.Certificate, error) {
129+
certificates, _, err := l.resources.gclient.Certificates.List(ctx, &godo.ListOptions{})
130+
if err != nil {
131+
return nil, fmt.Errorf("Failed to list certificates: %s", err)
132+
}
133+
134+
var certificate *godo.Certificate
135+
136+
for _, c := range certificates {
137+
if isValidCertificateForDomain(&c, domain) {
138+
certificate = &c
139+
break
140+
}
141+
}
142+
143+
return certificate, nil
144+
}
145+
146+
// generateCertificateForDomain creates a new certificate that is valid for the given domain. If the domain includes
147+
// a subdomain, the generated certificate will include DNSNames for both the root domain and the subdomain.
148+
func (l *loadBalancers) generateCertificateForDomain(ctx context.Context, domain *domain) (*godo.Certificate, error) {
149+
certName := getCertificateName(domain.full)
150+
dnsNames := []string{domain.root}
151+
152+
if domain.sub != "" {
153+
dnsNames = append(dnsNames, domain.full)
154+
}
155+
156+
certificateReq := &godo.CertificateRequest{
157+
Name: certName,
158+
DNSNames: dnsNames,
159+
Type: certTypeLetsEncrypt,
160+
}
161+
162+
klog.V(2).Infof("Generating new certificate for domain: %s", domain.full)
163+
certificate, _, err := l.resources.gclient.Certificates.Create(ctx, certificateReq)
164+
if err != nil {
165+
return nil, fmt.Errorf("failed to create certificate: %s", err)
166+
}
167+
168+
return certificate, nil
169+
}
170+
171+
// findARecordForNameAndIP searches the list of domain records for a Type A record with the given name
172+
// and data pointing to the given IP. If the named A record is found but pointing elsewhere, it throws an error.
173+
func findARecordForNameAndIP(records []godo.DomainRecord, name string, ip string) (*godo.DomainRecord, error) {
174+
var record *godo.DomainRecord
175+
176+
for _, r := range records {
177+
if r.Type != "A" || r.Name != name {
178+
continue
179+
}
180+
181+
if r.Data != ip {
182+
return nil, fmt.Errorf("the A record(%s) is already in use with another IP(%s)", name, r.Data)
183+
}
184+
185+
record = &r
186+
break
187+
}
188+
189+
return record, nil
190+
}
191+
192+
// ensureDomainARecords ensures that if the service has a domain annotation,
193+
// the domain has an A record for the full subdomain pointing to the loadbalancer
194+
func (l *loadBalancers) ensureDomainARecords(ctx context.Context, domain *domain, lb *godo.LoadBalancer) error {
195+
records, _, err := l.resources.gclient.Domains.Records(ctx, domain.root, &godo.ListOptions{})
196+
if err != nil {
197+
return fmt.Errorf("failed to fetch records for domain(%s): %s", domain.root, err)
198+
}
199+
200+
err = l.ensureDomainARecord(ctx, records, domain.root, "@", lb.IP)
201+
if err != nil {
202+
return err
203+
}
204+
205+
err = l.ensureDomainARecord(ctx, records, domain.root, domain.sub, lb.IP)
206+
if err != nil {
207+
return err
208+
}
209+
210+
return nil
211+
}
212+
213+
// ensureDomainARecord takes a list of records for a given domain and verifies the requested A record exists. If it does not, it generates
214+
// a new A record for the given domain, name, and IP.
215+
func (l *loadBalancers) ensureDomainARecord(ctx context.Context, records []godo.DomainRecord, domain string, name string, ip string) error {
216+
record, err := findARecordForNameAndIP(records, name, ip)
217+
if err != nil {
218+
return err
219+
}
220+
221+
if record == nil {
222+
_, _, err = l.resources.gclient.Domains.CreateRecord(ctx, domain, &godo.DomainRecordEditRequest{
223+
Type: "A",
224+
Name: name,
225+
Data: ip,
226+
TTL: defaultDomainRecordTTL,
227+
})
228+
if err != nil {
229+
return err
230+
}
231+
}
232+
233+
return nil
234+
}
235+
236+
// getCertificateName returns a prefixed certificate so we know to cleanup
237+
// certificate when a loadbalancer for the given domain is deleted
238+
func getCertificateName(fullDomain string) string {
239+
return fmt.Sprintf("%s%s", certPrefix, strings.ReplaceAll(fullDomain, ".", "-"))
240+
}

0 commit comments

Comments
 (0)