blob: 2bfe9f0a888eb866ed8edfa59ccb727817574cbc [file] [log] [blame]
Giorgi Lekveishvili0048a782023-06-20 18:32:21 +04001package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
9
10 "github.com/jetstack/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
11 "github.com/jetstack/cert-manager/pkg/acme/webhook/cmd"
12 cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1"
13 extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
14 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15 "k8s.io/client-go/kubernetes"
16 "k8s.io/client-go/rest"
17 "k8s.io/klog/v2"
18)
19
20const (
21 GandiMinTtl = 300 // Gandi reports an error for values < this value
22)
23
24var GroupName = os.Getenv("GROUP_NAME")
25
26func main() {
27 if GroupName == "" {
28 panic("GROUP_NAME must be specified")
29 }
30
31 // This will register our gandi DNS provider with the webhook serving
32 // library, making it available as an API under the provided GroupName.
33 // You can register multiple DNS provider implementations with a single
34 // webhook, where the Name() method will be used to disambiguate between
35 // the different implementations.
36 cmd.RunWebhookServer(GroupName,
37 &gandiDNSProviderSolver{},
38 )
39}
40
41// gandiDNSProviderSolver implements the provider-specific logic needed to
42// 'present' an ACME challenge TXT record for your own DNS provider.
43// To do so, it must implement the `github.com/jetstack/cert-manager/pkg/acme/webhook.Solver`
44// interface.
45type gandiDNSProviderSolver struct {
46 client *kubernetes.Clientset
47}
48
49// gandiDNSProviderConfig is a structure that is used to decode into when
50// solving a DNS01 challenge.
51// This information is provided by cert-manager, and may be a reference to
52// additional configuration that's needed to solve the challenge for this
53// particular certificate or issuer.
54// This typically includes references to Secret resources containing DNS
55// provider credentials, in cases where a 'multi-tenant' DNS solver is being
56// created.
57// If you do *not* require per-issuer or per-certificate configuration to be
58// provided to your webhook, you can skip decoding altogether in favour of
59// using CLI flags or similar to provide configuration.
60// You should not include sensitive information here. If credentials need to
61// be used by your provider here, you should reference a Kubernetes Secret
62// resource and fetch these credentials using a Kubernetes clientset.
63type gandiDNSProviderConfig struct {
64 // These fields will be set by users in the
65 // `issuer.spec.acme.dns01.providers.webhook.config` field.
66 APIKeySecretRef cmmeta.SecretKeySelector `json:"apiKeySecretRef"`
67}
68
69// Name is used as the name for this DNS solver when referencing it on the ACME
70// Issuer resource.
71// This should be unique **within the group name**, i.e. you can have two
72// solvers configured with the same Name() **so long as they do not co-exist
73// within a single webhook deployment**.
74// For example, `cloudflare` may be used as the name of a solver.
75func (c *gandiDNSProviderSolver) Name() string {
76 return "gandi"
77}
78
79// Present is responsible for actually presenting the DNS record with the
80// DNS provider.
81// This method should tolerate being called multiple times with the same value.
82// cert-manager itself will later perform a self check to ensure that the
83// solver has correctly configured the DNS provider.
84func (c *gandiDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
85 klog.V(6).Infof("call function Present: namespace=%s, zone=%s, fqdn=%s",
86 ch.ResourceNamespace, ch.ResolvedZone, ch.ResolvedFQDN)
87
88 cfg, err := loadConfig(ch.Config)
89 if err != nil {
90 return fmt.Errorf("unable to load config: %v", err)
91 }
92
93 klog.V(6).Infof("decoded configuration %v", cfg)
94
95 apiKey, err := c.getApiKey(&cfg, ch.ResourceNamespace)
96 if err != nil {
97 return fmt.Errorf("unable to get API key: %v", err)
98 }
99
100 gandiClient := NewGandiClient(*apiKey)
101
102 entry, domain := c.getDomainAndEntry(ch)
103 klog.V(6).Infof("present for entry=%s, domain=%s", entry, domain)
104
105 present, err := gandiClient.HasTxtRecord(&domain, &entry)
106 if err != nil {
107 return fmt.Errorf("unable to check TXT record: %v", err)
108 }
109
110 if present {
111 err := gandiClient.UpdateTxtRecord(&domain, &entry, &ch.Key, GandiMinTtl)
112 if err != nil {
113 return fmt.Errorf("unable to change TXT record: %v", err)
114 }
115 } else {
116 err := gandiClient.CreateTxtRecord(&domain, &entry, &ch.Key, GandiMinTtl)
117 if err != nil {
118 return fmt.Errorf("unable to create TXT record: %v", err)
119 }
120 }
121
122 return nil
123}
124
125// CleanUp should delete the relevant TXT record from the DNS provider console.
126// If multiple TXT records exist with the same record name (e.g.
127// _acme-challenge.example.com) then **only** the record with the same `key`
128// value provided on the ChallengeRequest should be cleaned up.
129// This is in order to facilitate multiple DNS validations for the same domain
130// concurrently.
131func (c *gandiDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
132 klog.V(6).Infof("call function CleanUp: namespace=%s, zone=%s, fqdn=%s",
133 ch.ResourceNamespace, ch.ResolvedZone, ch.ResolvedFQDN)
134
135 cfg, err := loadConfig(ch.Config)
136 if err != nil {
137 return err
138 }
139
140 apiKey, err := c.getApiKey(&cfg, ch.ResourceNamespace)
141 if err != nil {
142 return fmt.Errorf("unable to get API key: %v", err)
143 }
144
145 gandiClient := NewGandiClient(*apiKey)
146
147 entry, domain := c.getDomainAndEntry(ch)
148
149 present, err := gandiClient.HasTxtRecord(&domain, &entry)
150 if err != nil {
151 return fmt.Errorf("unable to check TXT record: %v", err)
152 }
153
154 if present {
155 klog.V(6).Infof("deleting entry=%s, domain=%s", entry, domain)
156 err := gandiClient.DeleteTxtRecord(&domain, &entry)
157 if err != nil {
158 return fmt.Errorf("unable to remove TXT record: %v", err)
159 }
160 }
161
162 return nil
163}
164
165// Initialize will be called when the webhook first starts.
166// This method can be used to instantiate the webhook, i.e. initialising
167// connections or warming up caches.
168// Typically, the kubeClientConfig parameter is used to build a Kubernetes
169// client that can be used to fetch resources from the Kubernetes API, e.g.
170// Secret resources containing credentials used to authenticate with DNS
171// provider accounts.
172// The stopCh can be used to handle early termination of the webhook, in cases
173// where a SIGTERM or similar signal is sent to the webhook process.
174func (c *gandiDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, _ <-chan struct{}) error {
175 klog.V(6).Infof("call function Initialize")
176 cl, err := kubernetes.NewForConfig(kubeClientConfig)
177 if err != nil {
178 return fmt.Errorf("unable to get k8s client: %v", err)
179 }
180 c.client = cl
181 return nil
182}
183
184// loadConfig is a small helper function that decodes JSON configuration into
185// the typed config struct.
186func loadConfig(cfgJSON *extapi.JSON) (gandiDNSProviderConfig, error) {
187 cfg := gandiDNSProviderConfig{}
188 // handle the 'base case' where no configuration has been provided
189 if cfgJSON == nil {
190 return cfg, nil
191 }
192 if err := json.Unmarshal(cfgJSON.Raw, &cfg); err != nil {
193 return cfg, fmt.Errorf("error decoding solver config: %v", err)
194 }
195
196 return cfg, nil
197}
198
199func (c *gandiDNSProviderSolver) getDomainAndEntry(ch *v1alpha1.ChallengeRequest) (string, string) {
200 // Both ch.ResolvedZone and ch.ResolvedFQDN end with a dot: '.'
201 entry := strings.TrimSuffix(ch.ResolvedFQDN, ch.ResolvedZone)
202 entry = strings.TrimSuffix(entry, ".")
203 domain := strings.TrimSuffix(ch.ResolvedZone, ".")
204 return entry, domain
205}
206
207// Get Gandi API key from Kubernetes secret.
208func (c *gandiDNSProviderSolver) getApiKey(cfg *gandiDNSProviderConfig, namespace string) (*string, error) {
209 secretName := cfg.APIKeySecretRef.LocalObjectReference.Name
210
211 klog.V(6).Infof("try to load secret `%s` with key `%s`", secretName, cfg.APIKeySecretRef.Key)
212
213 sec, err := c.client.CoreV1().Secrets(namespace).Get(context.Background(), secretName, metav1.GetOptions{})
214 if err != nil {
215 return nil, fmt.Errorf("unable to get secret `%s`; %v", secretName, err)
216 }
217
218 secBytes, ok := sec.Data[cfg.APIKeySecretRef.Key]
219 if !ok {
220 return nil, fmt.Errorf("key %q not found in secret \"%s/%s\"", cfg.APIKeySecretRef.Key,
221 cfg.APIKeySecretRef.LocalObjectReference.Name, namespace)
222 }
223
224 apiKey := string(secBytes)
225 return &apiKey, nil
226}