mirror of
https://github.com/cert-manager/webhook-example.git
synced 2025-07-02 23:05:48 +02:00
Merge pull request #1 from yn360/feat/add-sotoon-solver
feat: Add Sotoon solver
This commit is contained in:
commit
6ad6542a5c
9 changed files with 214 additions and 299 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -17,3 +17,4 @@ cert-manager-webhook-example
|
||||||
# Make artifacts
|
# Make artifacts
|
||||||
_out
|
_out
|
||||||
_test
|
_test
|
||||||
|
sotoon-api-key.yaml
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *exampleSolver) handleDNSRequest(w dns.ResponseWriter, req *dns.Msg) {
|
|
||||||
msg := new(dns.Msg)
|
|
||||||
msg.SetReply(req)
|
|
||||||
switch req.Opcode {
|
|
||||||
case dns.OpcodeQuery:
|
|
||||||
for _, q := range msg.Question {
|
|
||||||
if err := e.addDNSAnswer(q, msg, req); err != nil {
|
|
||||||
msg.SetRcode(req, dns.RcodeServerFailure)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteMsg(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *exampleSolver) addDNSAnswer(q dns.Question, msg *dns.Msg, req *dns.Msg) error {
|
|
||||||
switch q.Qtype {
|
|
||||||
// Always return loopback for any A query
|
|
||||||
case dns.TypeA:
|
|
||||||
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN A 127.0.0.1", q.Name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg.Answer = append(msg.Answer, rr)
|
|
||||||
return nil
|
|
||||||
|
|
||||||
// TXT records are the only important record for ACME dns-01 challenges
|
|
||||||
case dns.TypeTXT:
|
|
||||||
e.RLock()
|
|
||||||
record, found := e.txtRecords[q.Name]
|
|
||||||
e.RUnlock()
|
|
||||||
if !found {
|
|
||||||
msg.SetRcode(req, dns.RcodeNameError)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN TXT %s", q.Name, record))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg.Answer = append(msg.Answer, rr)
|
|
||||||
return nil
|
|
||||||
|
|
||||||
// NS and SOA are for authoritative lookups, return obviously invalid data
|
|
||||||
case dns.TypeNS:
|
|
||||||
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN NS ns.example-acme-webook.invalid.", q.Name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg.Answer = append(msg.Answer, rr)
|
|
||||||
return nil
|
|
||||||
case dns.TypeSOA:
|
|
||||||
rr, err := dns.NewRR(fmt.Sprintf("%s 5 IN SOA %s 20 5 5 5 5", "ns.example-acme-webook.invalid.", "ns.example-acme-webook.invalid."))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg.Answer = append(msg.Answer, rr)
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unimplemented record type %v", q.Qtype)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,68 +0,0 @@
|
||||||
// package example contains a self-contained example of a webhook that passes the cert-manager
|
|
||||||
// DNS conformance tests
|
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/cert-manager/cert-manager/pkg/acme/webhook"
|
|
||||||
acme "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
type exampleSolver struct {
|
|
||||||
name string
|
|
||||||
server *dns.Server
|
|
||||||
txtRecords map[string]string
|
|
||||||
sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *exampleSolver) Name() string {
|
|
||||||
return e.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *exampleSolver) Present(ch *acme.ChallengeRequest) error {
|
|
||||||
e.Lock()
|
|
||||||
e.txtRecords[ch.ResolvedFQDN] = ch.Key
|
|
||||||
e.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *exampleSolver) CleanUp(ch *acme.ChallengeRequest) error {
|
|
||||||
e.Lock()
|
|
||||||
delete(e.txtRecords, ch.ResolvedFQDN)
|
|
||||||
e.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *exampleSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error {
|
|
||||||
go func(done <-chan struct{}) {
|
|
||||||
<-done
|
|
||||||
if err := e.server.Shutdown(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
||||||
}
|
|
||||||
}(stopCh)
|
|
||||||
go func() {
|
|
||||||
if err := e.server.ListenAndServe(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(port string) webhook.Solver {
|
|
||||||
e := &exampleSolver{
|
|
||||||
name: "example",
|
|
||||||
txtRecords: make(map[string]string),
|
|
||||||
}
|
|
||||||
e.server = &dns.Server{
|
|
||||||
Addr: ":" + port,
|
|
||||||
Net: "udp",
|
|
||||||
Handler: dns.HandlerFunc(e.handleDNSRequest),
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"math/big"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
acme "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExampleSolver_Name(t *testing.T) {
|
|
||||||
port, _ := rand.Int(rand.Reader, big.NewInt(50000))
|
|
||||||
port = port.Add(port, big.NewInt(15534))
|
|
||||||
solver := New(port.String())
|
|
||||||
assert.Equal(t, "example", solver.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleSolver_Initialize(t *testing.T) {
|
|
||||||
port, _ := rand.Int(rand.Reader, big.NewInt(50000))
|
|
||||||
port = port.Add(port, big.NewInt(15534))
|
|
||||||
solver := New(port.String())
|
|
||||||
done := make(chan struct{})
|
|
||||||
err := solver.Initialize(nil, done)
|
|
||||||
assert.NoError(t, err, "Expected Initialize not to error")
|
|
||||||
close(done)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleSolver_Present_Cleanup(t *testing.T) {
|
|
||||||
port, _ := rand.Int(rand.Reader, big.NewInt(50000))
|
|
||||||
port = port.Add(port, big.NewInt(15534))
|
|
||||||
solver := New(port.String())
|
|
||||||
done := make(chan struct{})
|
|
||||||
err := solver.Initialize(nil, done)
|
|
||||||
assert.NoError(t, err, "Expected Initialize not to error")
|
|
||||||
|
|
||||||
validTestData := []struct {
|
|
||||||
hostname string
|
|
||||||
record string
|
|
||||||
}{
|
|
||||||
{"test1.example.com.", "testkey1"},
|
|
||||||
{"test2.example.com.", "testkey2"},
|
|
||||||
{"test3.example.com.", "testkey3"},
|
|
||||||
}
|
|
||||||
for _, test := range validTestData {
|
|
||||||
err := solver.Present(&acme.ChallengeRequest{
|
|
||||||
Action: acme.ChallengeActionPresent,
|
|
||||||
Type: "dns-01",
|
|
||||||
ResolvedFQDN: test.hostname,
|
|
||||||
Key: test.record,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err, "Unexpected error while presenting %v", t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve test data
|
|
||||||
for _, test := range validTestData {
|
|
||||||
msg := new(dns.Msg)
|
|
||||||
msg.Id = dns.Id()
|
|
||||||
msg.RecursionDesired = true
|
|
||||||
msg.Question = make([]dns.Question, 1)
|
|
||||||
msg.Question[0] = dns.Question{dns.Fqdn(test.hostname), dns.TypeTXT, dns.ClassINET}
|
|
||||||
in, err := dns.Exchange(msg, "127.0.0.1:"+port.String())
|
|
||||||
|
|
||||||
assert.NoError(t, err, "Presented record %s not resolvable", test.hostname)
|
|
||||||
assert.Len(t, in.Answer, 1, "RR response is of incorrect length")
|
|
||||||
assert.Equal(t, []string{test.record}, in.Answer[0].(*dns.TXT).Txt, "TXT record returned did not match presented record")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup test data
|
|
||||||
for _, test := range validTestData {
|
|
||||||
err := solver.CleanUp(&acme.ChallengeRequest{
|
|
||||||
Action: acme.ChallengeActionCleanUp,
|
|
||||||
Type: "dns-01",
|
|
||||||
ResolvedFQDN: test.hostname,
|
|
||||||
Key: test.record,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err, "Unexpected error while cleaning up %v", t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve test data
|
|
||||||
for _, test := range validTestData {
|
|
||||||
msg := new(dns.Msg)
|
|
||||||
msg.Id = dns.Id()
|
|
||||||
msg.RecursionDesired = true
|
|
||||||
msg.Question = make([]dns.Question, 1)
|
|
||||||
msg.Question[0] = dns.Question{dns.Fqdn(test.hostname), dns.TypeTXT, dns.ClassINET}
|
|
||||||
in, err := dns.Exchange(msg, "127.0.0.1:"+port.String())
|
|
||||||
|
|
||||||
assert.NoError(t, err, "Presented record %s not resolvable", test.hostname)
|
|
||||||
assert.Len(t, in.Answer, 0, "RR response is of incorrect length")
|
|
||||||
assert.Equal(t, dns.RcodeNameError, in.Rcode, "Expexted NXDOMAIN")
|
|
||||||
}
|
|
||||||
|
|
||||||
close(done)
|
|
||||||
}
|
|
249
main.go
249
main.go
|
@ -1,15 +1,21 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
|
|
||||||
"github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
|
"github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
|
||||||
"github.com/cert-manager/cert-manager/pkg/acme/webhook/cmd"
|
"github.com/cert-manager/cert-manager/pkg/acme/webhook/cmd"
|
||||||
|
v1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
|
||||||
|
extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
var GroupName = os.Getenv("GROUP_NAME")
|
var GroupName = os.Getenv("GROUP_NAME")
|
||||||
|
@ -25,46 +31,37 @@ func main() {
|
||||||
// webhook, where the Name() method will be used to disambiguate between
|
// webhook, where the Name() method will be used to disambiguate between
|
||||||
// the different implementations.
|
// the different implementations.
|
||||||
cmd.RunWebhookServer(GroupName,
|
cmd.RunWebhookServer(GroupName,
|
||||||
&customDNSProviderSolver{},
|
&sotoonDNSProviderSolver{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// customDNSProviderSolver implements the provider-specific logic needed to
|
// sotoonDNSProviderSolver implements Sotoon DNS logic needed to
|
||||||
// 'present' an ACME challenge TXT record for your own DNS provider.
|
// 'present' an ACME challenge TXT record. To do so, it must implement
|
||||||
// To do so, it must implement the `github.com/cert-manager/cert-manager/pkg/acme/webhook.Solver`
|
// the `github.com/cert-manager/cert-manager/pkg/acme/webhook.Solver`
|
||||||
// interface.
|
// interface.
|
||||||
type customDNSProviderSolver struct {
|
type sotoonDNSProviderSolver struct {
|
||||||
// If a Kubernetes 'clientset' is needed, you must:
|
// If a Kubernetes 'clientset' is needed, you must:
|
||||||
// 1. uncomment the additional `client` field in this structure below
|
// 1. uncomment the additional `client` field in this structure below
|
||||||
// 2. uncomment the "k8s.io/client-go/kubernetes" import at the top of the file
|
// 2. uncomment the "k8s.io/client-go/kubernetes" import at the top of the file
|
||||||
// 3. uncomment the relevant code in the Initialize method below
|
// 3. uncomment the relevant code in the Initialize method below
|
||||||
// 4. ensure your webhook's service account has the required RBAC role
|
// 4. ensure your webhook's service account has the required RBAC role
|
||||||
// assigned to it for interacting with the Kubernetes APIs you need.
|
// 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
|
// sootonNSProviderConfig is a structure that is used to decode into when
|
||||||
// solving a DNS01 challenge.
|
// solving a DNS01 challenge.
|
||||||
// This information is provided by cert-manager, and may be a reference to
|
// This information is provided by cert-manager, and a reference to credentials
|
||||||
// additional configuration that's needed to solve the challenge for this
|
// that's needed to add TXT record in Sotoon to solve the challenge for this
|
||||||
// particular certificate or issuer.
|
// particular certificate. If credentials need to be used by your provider here,
|
||||||
// This typically includes references to Secret resources containing DNS
|
// you should reference a Kubernetes Secret resource and fetch these credentials
|
||||||
// provider credentials, in cases where a 'multi-tenant' DNS solver is being
|
// using a Kubernetes clientset.
|
||||||
// created.
|
type sotoonDNSProviderConfig struct {
|
||||||
// If you do *not* require per-issuer or per-certificate configuration to be
|
// This field will be set by users in the
|
||||||
// provided to your webhook, you can skip decoding altogether in favour of
|
|
||||||
// using CLI flags or similar to provide configuration.
|
|
||||||
// You should not include sensitive information here. If credentials need to
|
|
||||||
// be used by your provider here, you should reference a Kubernetes Secret
|
|
||||||
// resource and fetch these credentials using a Kubernetes clientset.
|
|
||||||
type customDNSProviderConfig struct {
|
|
||||||
// Change the two fields below according to the format of the configuration
|
|
||||||
// to be decoded.
|
|
||||||
// These fields will be set by users in the
|
|
||||||
// `issuer.spec.acme.dns01.providers.webhook.config` field.
|
// `issuer.spec.acme.dns01.providers.webhook.config` field.
|
||||||
|
|
||||||
//Email string `json:"email"`
|
APIKeySecretRef v1.SecretKeySelector `json:"apiKeySecretRef"`
|
||||||
//APIKeySecretRef v1alpha1.SecretKeySelector `json:"apiKeySecretRef"`
|
BaseUrl string `json:"baseUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name is used as the name for this DNS solver when referencing it on the ACME
|
// Name is used as the name for this DNS solver when referencing it on the ACME
|
||||||
|
@ -73,8 +70,105 @@ type customDNSProviderConfig struct {
|
||||||
// solvers configured with the same Name() **so long as they do not co-exist
|
// solvers configured with the same Name() **so long as they do not co-exist
|
||||||
// within a single webhook deployment**.
|
// within a single webhook deployment**.
|
||||||
// For example, `cloudflare` may be used as the name of a solver.
|
// For example, `cloudflare` may be used as the name of a solver.
|
||||||
func (c *customDNSProviderSolver) Name() string {
|
func (s *sotoonDNSProviderSolver) Name() string {
|
||||||
return "my-custom-solver"
|
return "sotoon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDotFromEnd will remove . at the end of resolvedZone
|
||||||
|
// Sotoon doesn't recognize dot at the end of zone names.
|
||||||
|
func removeDotFromEnd(resolvedZone string) string {
|
||||||
|
if resolvedZone[len(resolvedZone)-1:] == "." {
|
||||||
|
return resolvedZone[0 : len(resolvedZone)-1]
|
||||||
|
}
|
||||||
|
return resolvedZone
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeResolvedFQDN will remove resolvedZone from resolvedFQDN
|
||||||
|
// Sotoon doesn't recognize full names that include zone in them and will consider them part
|
||||||
|
// of the name. For example if `ResolvedFQDN` is "_acme-challenge.example.com" in "example.com"
|
||||||
|
// zone the record will be "_acme-challenge.example.com.example.com", so we need to remove
|
||||||
|
// the added ".example.com" from it.
|
||||||
|
func normalizeResolvedFQDN(resolvedFQDN, resolvedZone string) string {
|
||||||
|
if resolvedFQDN[len(resolvedFQDN)-len(resolvedZone):] == resolvedZone {
|
||||||
|
// Example: resolvedFQDN = "_acme-challenge.example.com.", resolvedZone = "example.com."
|
||||||
|
// resolvedFQDN[:len(resolvedFQDN)-len(resolvedZone)-1] = _acme-challenge
|
||||||
|
return resolvedFQDN[:len(resolvedFQDN)-len(resolvedZone)-1]
|
||||||
|
}
|
||||||
|
return removeDotFromEnd(resolvedFQDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getApiKey retrieves apiKey from secret provided to solver in `APIKeySecretRef`
|
||||||
|
func (s *sotoonDNSProviderSolver) getApiKey(secretNamespace string, secretRef v1.SecretKeySelector) (string, error) {
|
||||||
|
secret, err := s.client.CoreV1().Secrets(secretNamespace).Get(
|
||||||
|
context.Background(), secretRef.Name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("couldn't retrieve secret %s from namespace %s", secretRef.Name, secretNamespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretByteString, ok := secret.Data[secretRef.Key]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("couldn't fetch apikey from key %q of secret %s in namespace %s",
|
||||||
|
secretRef.Key, secretRef.Name, secretNamespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(secretByteString), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllRecords retrieves all records from Sotoon DNS and returns them in a map of string to interface{}
|
||||||
|
func (s *sotoonDNSProviderSolver) getAllRecords(baseUrl, resolvedZone, apiKey string) (map[string]interface{}, error) {
|
||||||
|
httpClient := &http.Client{}
|
||||||
|
dnsUrl := fmt.Sprintf("%s/%s", baseUrl, resolvedZone)
|
||||||
|
req, err := http.NewRequest("GET", dnsUrl, nil)
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't create http request object with error %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request to Sotoon API failed with error %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't read Sotoon dns response's body with error %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsData map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &dnsData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't parse Sotoon dns response's body %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dnsData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsChangeQuery receives a payload in query in the format of `[{"op":"<op>","path":"<path>"}]` and
|
||||||
|
// updates DNS records based on it. For more information look at the requests that ocean panel sends
|
||||||
|
// to Sotoon's API.
|
||||||
|
func (s *sotoonDNSProviderSolver) dnsChangeQuery(baseUrl, resolvedZone, apiKey, query string) error {
|
||||||
|
httpClient := &http.Client{}
|
||||||
|
dnsUrl := fmt.Sprintf("%s/%s", baseUrl, resolvedZone)
|
||||||
|
patchPayload := []byte(query)
|
||||||
|
req, err := http.NewRequest("PATCH", dnsUrl, bytes.NewBuffer(patchPayload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
|
||||||
|
req.Header.Set("Content-type", "application/json-patch+json")
|
||||||
|
|
||||||
|
response, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request to Sotoon API failed with error %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("couldn't change record in zone %s with response status code of: %d",
|
||||||
|
resolvedZone, response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Present is responsible for actually presenting the DNS record with the
|
// Present is responsible for actually presenting the DNS record with the
|
||||||
|
@ -82,16 +176,43 @@ func (c *customDNSProviderSolver) Name() string {
|
||||||
// This method should tolerate being called multiple times with the same value.
|
// 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
|
// cert-manager itself will later perform a self check to ensure that the
|
||||||
// solver has correctly configured the DNS provider.
|
// solver has correctly configured the DNS provider.
|
||||||
func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
|
func (s *sotoonDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
|
||||||
cfg, err := loadConfig(ch.Config)
|
cfg, err := loadConfig(ch.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: do something more useful with the decoded configuration
|
apiKey, err := s.getApiKey(ch.ResourceNamespace, cfg.APIKeySecretRef)
|
||||||
fmt.Printf("Decoded configuration %v", cfg)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsData, err := s.getAllRecords(cfg.BaseUrl, removeDotFromEnd(ch.ResolvedZone), apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedResolvedFQDN := normalizeResolvedFQDN(ch.ResolvedFQDN, ch.ResolvedZone)
|
||||||
|
// Check whether the key is already present in DNS
|
||||||
|
for recordName, records := range dnsData["spec"].(map[string]interface{})["records"].(map[string]interface{}) {
|
||||||
|
if recordName == normalizedResolvedFQDN {
|
||||||
|
for _, record := range records.([]interface{}) {
|
||||||
|
val, ok := record.(map[string]interface{})["TXT"]
|
||||||
|
if ok && val.(string) == ch.Key {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If code reaches here the key is not present and we should add a record containing that key
|
||||||
|
patchPayload := fmt.Sprintf(`[{"op":"add","path":"/spec/records/%s","value":[{"TXT":"%s","ttl":300}]}]`,
|
||||||
|
normalizedResolvedFQDN, ch.Key)
|
||||||
|
err = s.dnsChangeQuery(cfg.BaseUrl, removeDotFromEnd(ch.ResolvedZone), apiKey, patchPayload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: add code that sets a record in the DNS provider's console
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,8 +222,43 @@ func (c *customDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error {
|
||||||
// value provided on the ChallengeRequest should be cleaned up.
|
// value provided on the ChallengeRequest should be cleaned up.
|
||||||
// This is in order to facilitate multiple DNS validations for the same domain
|
// This is in order to facilitate multiple DNS validations for the same domain
|
||||||
// concurrently.
|
// concurrently.
|
||||||
func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
|
func (s *sotoonDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
|
||||||
// TODO: add code that deletes a record from the DNS provider's console
|
cfg, err := loadConfig(ch.Config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err := s.getApiKey(ch.ResourceNamespace, cfg.APIKeySecretRef)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsData, err := s.getAllRecords(cfg.BaseUrl, removeDotFromEnd(ch.ResolvedZone), apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find record and delete it
|
||||||
|
normalizedResolvedFQDN := normalizeResolvedFQDN(ch.ResolvedFQDN, ch.ResolvedZone)
|
||||||
|
for recordName, records := range dnsData["spec"].(map[string]interface{})["records"].(map[string]interface{}) {
|
||||||
|
if recordName == normalizedResolvedFQDN {
|
||||||
|
for recordIndex, record := range records.([]interface{}) {
|
||||||
|
val, ok := record.(map[string]interface{})["TXT"]
|
||||||
|
if ok && val.(string) == ch.Key {
|
||||||
|
// Record found
|
||||||
|
patchPayload := fmt.Sprintf(`[{"op":"remove","path":"/spec/records/%s/%d"}]`,
|
||||||
|
normalizedResolvedFQDN, recordIndex)
|
||||||
|
err := s.dnsChangeQuery(cfg.BaseUrl, removeDotFromEnd(ch.ResolvedZone), apiKey, patchPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("an error occurred while deleting DNS Record %s with value of %s",
|
||||||
|
normalizedResolvedFQDN, ch.Key)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,25 +271,22 @@ func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
|
||||||
// provider accounts.
|
// provider accounts.
|
||||||
// The stopCh can be used to handle early termination of the webhook, in cases
|
// The stopCh can be used to handle early termination of the webhook, in cases
|
||||||
// where a SIGTERM or similar signal is sent to the webhook process.
|
// where a SIGTERM or similar signal is sent to the webhook process.
|
||||||
func (c *customDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error {
|
func (s *sotoonDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error {
|
||||||
///// UNCOMMENT THE BELOW CODE TO MAKE A KUBERNETES CLIENTSET AVAILABLE TO
|
// Initializing kubernetes client to access credentials secret
|
||||||
///// YOUR CUSTOM DNS PROVIDER
|
cl, err := kubernetes.NewForConfig(kubeClientConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
//cl, err := kubernetes.NewForConfig(kubeClientConfig)
|
s.client = cl
|
||||||
//if err != nil {
|
|
||||||
// return err
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//c.client = cl
|
|
||||||
|
|
||||||
///// END OF CODE TO MAKE KUBERNETES CLIENTSET AVAILABLE
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig is a small helper function that decodes JSON configuration into
|
// loadConfig is a small helper function that decodes JSON configuration into
|
||||||
// the typed config struct.
|
// the typed config struct.
|
||||||
func loadConfig(cfgJSON *extapi.JSON) (customDNSProviderConfig, error) {
|
func loadConfig(cfgJSON *extapi.JSON) (sotoonDNSProviderConfig, error) {
|
||||||
cfg := customDNSProviderConfig{}
|
cfg := sotoonDNSProviderConfig{}
|
||||||
// handle the 'base case' where no configuration has been provided
|
// handle the 'base case' where no configuration has been provided
|
||||||
if cfgJSON == nil {
|
if cfgJSON == nil {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|
22
main_test.go
22
main_test.go
|
@ -5,8 +5,6 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
acmetest "github.com/cert-manager/cert-manager/test/acme"
|
acmetest "github.com/cert-manager/cert-manager/test/acme"
|
||||||
|
|
||||||
"github.com/cert-manager/webhook-example/example"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -17,22 +15,12 @@ func TestRunsSuite(t *testing.T) {
|
||||||
// The manifest path should contain a file named config.json that is a
|
// The manifest path should contain a file named config.json that is a
|
||||||
// snippet of valid configuration that should be included on the
|
// snippet of valid configuration that should be included on the
|
||||||
// ChallengeRequest passed as part of the test cases.
|
// ChallengeRequest passed as part of the test cases.
|
||||||
//
|
fixture := acmetest.NewFixture(&sotoonDNSProviderSolver{},
|
||||||
|
acmetest.SetResolvedZone(zone),
|
||||||
// Uncomment the below fixture when implementing your custom DNS provider
|
acmetest.SetAllowAmbientCredentials(false),
|
||||||
//fixture := acmetest.NewFixture(&customDNSProviderSolver{},
|
acmetest.SetManifestPath("testdata/sotoon-solver"),
|
||||||
// acmetest.SetResolvedZone(zone),
|
|
||||||
// acmetest.SetAllowAmbientCredentials(false),
|
|
||||||
// acmetest.SetManifestPath("testdata/my-custom-solver"),
|
|
||||||
// acmetest.SetBinariesPath("_test/kubebuilder/bin"),
|
|
||||||
//)
|
|
||||||
solver := example.New("59351")
|
|
||||||
fixture := acmetest.NewFixture(solver,
|
|
||||||
acmetest.SetResolvedZone("example.com."),
|
|
||||||
acmetest.SetManifestPath("testdata/my-custom-solver"),
|
|
||||||
acmetest.SetDNSServer("127.0.0.1:59351"),
|
|
||||||
acmetest.SetUseAuthoritative(false),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//need to uncomment and RunConformance delete runBasic and runExtended once https://github.com/cert-manager/cert-manager/pull/4835 is merged
|
//need to uncomment and RunConformance delete runBasic and runExtended once https://github.com/cert-manager/cert-manager/pull/4835 is merged
|
||||||
//fixture.RunConformance(t)
|
//fixture.RunConformance(t)
|
||||||
fixture.RunBasic(t)
|
fixture.RunBasic(t)
|
||||||
|
|
1
testdata/my-custom-solver/config.json
vendored
1
testdata/my-custom-solver/config.json
vendored
|
@ -1 +0,0 @@
|
||||||
{}
|
|
7
testdata/sotoon-solver/config.json
vendored
Normal file
7
testdata/sotoon-solver/config.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"apiKeySecretRef": {
|
||||||
|
"name": "sotoon-api-key",
|
||||||
|
"key": "key"
|
||||||
|
},
|
||||||
|
"baseUrl": "https://api.delivery.sotoon.ir/apis/delivery.cafebazaar.cloud%2Fv1beta1/namespaces/yektanet/domainzones"
|
||||||
|
}
|
Loading…
Reference in a new issue