diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..3018f3c --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,57 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/hex" + "fmt" + "math/big" + "strconv" + "strings" + "time" +) + +var ( + saltChars = "0123456789ABCDEFGHIJKLMNOPQRTSUVWXYZabcdefghijklmnopqrstuvwxyz" + baseUrl = "https://api.nearlyfreespeech.net" + authHeaderName = "X-NFSN-Authentication" + maxSalt big.Int +) + +func init() { + maxSalt.SetInt64(int64(len(saltChars))) +} + +func Salt() (string, error) { + salt := make([]string, 16) + for i := 0; i < 16; i++ { + rand_index, err := rand.Int(rand.Reader, &maxSalt) + if err != nil { + return "", err + } + + salt[i] = string(saltChars[rand_index.Int64()]) + } + + return strings.Join(salt, ""), nil +} + +func Timestamp() string { + return strconv.FormatInt(time.Now().Unix(), 10) +} + +func ComputeHash(data string) string { + result := sha1.Sum([]byte(data)) + return hex.EncodeToString(result[:]) +} + +func GetAuthHeader(login string, apiKey string, url string, body string) (string, error) { + timestamp := Timestamp() + salt, err := Salt() + if err != nil { + return "", err + } + bodyHash := ComputeHash(body) + hash := ComputeHash(fmt.Sprintf("%s;%s;%s;%s;%s;%s", login, timestamp, salt, apiKey, url, bodyHash)) + return fmt.Sprintf("%s;%s;%s;%s", login, timestamp, salt, hash), nil +} diff --git a/deploy/example-webhook/values.yaml b/deploy/example-webhook/values.yaml index 31eb151..fe72b0a 100644 --- a/deploy/example-webhook/values.yaml +++ b/deploy/example-webhook/values.yaml @@ -13,8 +13,8 @@ certManager: serviceAccountName: cert-manager image: - repository: mycompany/webhook-image - tag: latest + repository: tevildo/cert-manager-webhook-nearlyfreespeech + tag: 0.0.7 pullPolicy: IfNotPresent nameOverride: "" diff --git a/dns/dns.go b/dns/dns.go new file mode 100644 index 0000000..237321c --- /dev/null +++ b/dns/dns.go @@ -0,0 +1,68 @@ +package dns + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + + "github.com/MartinWilkerson/cert-manager-webhook-nearlyfreespeech/auth" +) + +var ( + baseUrl = "https://api.nearlyfreespeech.net" + authHeaderName = "X-NFSN-Authentication" +) + +func SetTXTRecord(domain string, dnsName string, key string, login string, apiKey string) error { + urlPath := fmt.Sprintf("/dns/%s/addRR", domain) + requestUrl := fmt.Sprintf("%s%s", baseUrl, urlPath) + log.Printf("Request URL: %v", requestUrl) + + values := url.Values{"name": {dnsName}, "type": {"TXT"}, "data": {key}} + body := values.Encode() + return send(login, apiKey, urlPath, body, requestUrl) +} + +func ClearTXTRecord(domain string, dnsName string, key string, login string, apiKey string) error { + urlPath := fmt.Sprintf("/dns/%s/removeRR", domain) + requestUrl := fmt.Sprintf("%s%s", baseUrl, urlPath) + + values := url.Values{"name": {dnsName}, "type": {"TXT"}, "data": {key}} + body := values.Encode() + return send(login, apiKey, urlPath, body, requestUrl) +} + +func send(login string, apiKey string, urlPath string, body string, requestUrl string) error { + authHeader, err := auth.GetAuthHeader(login, apiKey, urlPath, body) + if err != nil { + return err + } + + bodyReader := strings.NewReader(body) + req, err := http.NewRequest("POST", requestUrl, bodyReader) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(authHeaderName, authHeader) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return fmt.Errorf("HTTP error code %v: %v", resp.StatusCode, string(bytes)) + } + + return nil +} diff --git a/go.mod b/go.mod index c62945a..76ff6fb 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/cert-manager/webhook-example +module github.com/MartinWilkerson/cert-manager-webhook-nearlyfreespeech go 1.20 diff --git a/main.go b/main.go index 969e6d2..eb2c3a8 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,22 @@ package main import ( + "context" "encoding/json" "fmt" + "log" "os" + "strings" extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" + "github.com/MartinWilkerson/cert-manager-webhook-nearlyfreespeech/dns" + acme_v1alpha1 "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" "github.com/cert-manager/cert-manager/pkg/acme/webhook/cmd" + meta_v1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" ) var GroupName = os.Getenv("GROUP_NAME") @@ -40,7 +47,7 @@ type customDNSProviderSolver struct { // 3. uncomment the relevant code in the Initialize method below // 4. ensure your webhook's service account has the required RBAC role // assigned to it for interacting with the Kubernetes APIs you need. - //client kubernetes.Clientset + client *kubernetes.Clientset } // customDNSProviderConfig is a structure that is used to decode into when @@ -64,7 +71,8 @@ type customDNSProviderConfig struct { // `issuer.spec.acme.dns01.providers.webhook.config` field. //Email string `json:"email"` - //APIKeySecretRef v1alpha1.SecretKeySelector `json:"apiKeySecretRef"` + Login string `json:"login"` + APIKeySecretRef meta_v1.SecretKeySelector `json:"apiKeySecretRef"` } // Name is used as the name for this DNS solver when referencing it on the ACME @@ -74,7 +82,7 @@ type customDNSProviderConfig struct { // within a single webhook deployment**. // For example, `cloudflare` may be used as the name of a solver. func (c *customDNSProviderSolver) Name() string { - return "my-custom-solver" + return "nearlyfreespeech" } // Present is responsible for actually presenting the DNS record with the @@ -82,17 +90,28 @@ func (c *customDNSProviderSolver) Name() string { // This method should tolerate being called multiple times with the same value. // cert-manager itself will later perform a self check to ensure that the // solver has correctly configured the DNS provider. -func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { +func (c *customDNSProviderSolver) Present(ch *acme_v1alpha1.ChallengeRequest) error { cfg, err := loadConfig(ch.Config) if err != nil { return err } // TODO: do something more useful with the decoded configuration - fmt.Printf("Decoded configuration %v", cfg) + log.Printf("Decoded configuration %v", cfg) + log.Printf("ResolvedZone: '%v'. ResolvedFQDN: '%v'", ch.ResolvedZone, ch.ResolvedFQDN) // TODO: add code that sets a record in the DNS provider's console - return nil + apiKeySecret, err := c.client.CoreV1().Secrets(ch.ResourceNamespace).Get(context.TODO(), cfg.APIKeySecretRef.Name, v1.GetOptions{}) + if err != nil { + return err + } + apiKeyData := apiKeySecret.Data[cfg.APIKeySecretRef.Key] + apiKey := string(apiKeyData) + + dnsName := strings.TrimSuffix(ch.ResolvedFQDN, "."+ch.ResolvedZone) + domain := strings.TrimSuffix(ch.ResolvedZone, ".") + + return dns.SetTXTRecord(domain, dnsName, ch.Key, cfg.Login, apiKey) } // CleanUp should delete the relevant TXT record from the DNS provider console. @@ -101,9 +120,22 @@ func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { // value provided on the ChallengeRequest should be cleaned up. // This is in order to facilitate multiple DNS validations for the same domain // concurrently. -func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { - // TODO: add code that deletes a record from the DNS provider's console - return nil +func (c *customDNSProviderSolver) CleanUp(ch *acme_v1alpha1.ChallengeRequest) error { + cfg, err := loadConfig(ch.Config) + if err != nil { + return err + } + + apiKeySecret, err := c.client.CoreV1().Secrets(ch.ResourceNamespace).Get(context.TODO(), cfg.APIKeySecretRef.Name, v1.GetOptions{}) + if err != nil { + return err + } + apiKeyData := apiKeySecret.Data[cfg.APIKeySecretRef.Key] + apiKey := string(apiKeyData) + + dnsName := strings.TrimSuffix(ch.ResolvedFQDN, "."+ch.ResolvedZone) + + return dns.ClearTXTRecord(ch.ResolvedZone, dnsName, ch.Key, cfg.Login, apiKey) } // Initialize will be called when the webhook first starts. @@ -119,12 +151,12 @@ func (c *customDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stop ///// UNCOMMENT THE BELOW CODE TO MAKE A KUBERNETES CLIENTSET AVAILABLE TO ///// YOUR CUSTOM DNS PROVIDER - //cl, err := kubernetes.NewForConfig(kubeClientConfig) - //if err != nil { - // return err - //} - // - //c.client = cl + cl, err := kubernetes.NewForConfig(kubeClientConfig) + if err != nil { + return err + } + + c.client = cl ///// END OF CODE TO MAKE KUBERNETES CLIENTSET AVAILABLE return nil diff --git a/main_test.go b/main_test.go index 1d7d5ff..99c42b3 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,7 @@ import ( acmetest "github.com/cert-manager/cert-manager/test/acme" - "github.com/cert-manager/webhook-example/example" + "github.com/MartinWilkerson/cert-manager-webhook-nearlyfreespeech/example" ) var (