From 03ea58ac3de1b30585750f533258d5e6919bcccb Mon Sep 17 00:00:00 2001 From: Parsa Date: Sat, 9 Mar 2024 13:04:03 +0330 Subject: [PATCH] feat: Add Sotoon solver --- .gitignore | 1 + example/dns.go | 69 ----- example/example.go | 68 ----- example/example_test.go | 96 ------- main.go | 249 ++++++++++++++---- main_test.go | 22 +- testdata/my-custom-solver/config.json | 1 - .../README.md | 0 testdata/sotoon-solver/config.json | 7 + 9 files changed, 214 insertions(+), 299 deletions(-) delete mode 100644 example/dns.go delete mode 100644 example/example.go delete mode 100644 example/example_test.go delete mode 100644 testdata/my-custom-solver/config.json rename testdata/{my-custom-solver => sotoon-solver}/README.md (100%) create mode 100644 testdata/sotoon-solver/config.json diff --git a/.gitignore b/.gitignore index a4be81c..4d24f43 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ cert-manager-webhook-example # Make artifacts _out _test +sotoon-api-key.yaml diff --git a/example/dns.go b/example/dns.go deleted file mode 100644 index e29597e..0000000 --- a/example/dns.go +++ /dev/null @@ -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) - } -} diff --git a/example/example.go b/example/example.go deleted file mode 100644 index 8cfe59e..0000000 --- a/example/example.go +++ /dev/null @@ -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 -} diff --git a/example/example_test.go b/example/example_test.go deleted file mode 100644 index ef4dde3..0000000 --- a/example/example_test.go +++ /dev/null @@ -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) -} diff --git a/main.go b/main.go index 969e6d2..3a73fbe 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,21 @@ package main import ( + "bytes" + "context" "encoding/json" "fmt" + "io" + "net/http" "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/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") @@ -25,46 +31,37 @@ func main() { // webhook, where the Name() method will be used to disambiguate between // the different implementations. cmd.RunWebhookServer(GroupName, - &customDNSProviderSolver{}, + &sotoonDNSProviderSolver{}, ) } -// customDNSProviderSolver implements the provider-specific logic needed to -// 'present' an ACME challenge TXT record for your own DNS provider. -// To do so, it must implement the `github.com/cert-manager/cert-manager/pkg/acme/webhook.Solver` +// sotoonDNSProviderSolver implements Sotoon DNS logic needed to +// 'present' an ACME challenge TXT record. To do so, it must implement +// the `github.com/cert-manager/cert-manager/pkg/acme/webhook.Solver` // interface. -type customDNSProviderSolver struct { +type sotoonDNSProviderSolver struct { // If a Kubernetes 'clientset' is needed, you must: // 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 // 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 +// sootonNSProviderConfig is a structure that is used to decode into when // solving a DNS01 challenge. -// This information is provided by cert-manager, and may be a reference to -// additional configuration that's needed to solve the challenge for this -// particular certificate or issuer. -// This typically includes references to Secret resources containing DNS -// provider credentials, in cases where a 'multi-tenant' DNS solver is being -// created. -// If you do *not* require per-issuer or per-certificate configuration to be -// 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 +// This information is provided by cert-manager, and a reference to credentials +// that's needed to add TXT record in Sotoon to solve the challenge for this +// particular certificate. 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 sotoonDNSProviderConfig struct { + // This field will be set by users in the // `issuer.spec.acme.dns01.providers.webhook.config` field. - //Email string `json:"email"` - //APIKeySecretRef v1alpha1.SecretKeySelector `json:"apiKeySecretRef"` + APIKeySecretRef v1.SecretKeySelector `json:"apiKeySecretRef"` + BaseUrl string `json:"baseUrl"` } // 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 // 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" +func (s *sotoonDNSProviderSolver) Name() string { + 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":"","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 @@ -82,16 +176,43 @@ 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 (s *sotoonDNSProviderSolver) Present(ch *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) + 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 + } + + 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 } @@ -101,8 +222,43 @@ 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 +func (s *sotoonDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { + 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 } @@ -115,25 +271,22 @@ func (c *customDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { // provider accounts. // 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. -func (c *customDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { - ///// UNCOMMENT THE BELOW CODE TO MAKE A KUBERNETES CLIENTSET AVAILABLE TO - ///// YOUR CUSTOM DNS PROVIDER +func (s *sotoonDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { + // Initializing kubernetes client to access credentials secret + cl, err := kubernetes.NewForConfig(kubeClientConfig) + if err != nil { + return err + } - //cl, err := kubernetes.NewForConfig(kubeClientConfig) - //if err != nil { - // return err - //} - // - //c.client = cl + s.client = cl - ///// END OF CODE TO MAKE KUBERNETES CLIENTSET AVAILABLE return nil } // loadConfig is a small helper function that decodes JSON configuration into // the typed config struct. -func loadConfig(cfgJSON *extapi.JSON) (customDNSProviderConfig, error) { - cfg := customDNSProviderConfig{} +func loadConfig(cfgJSON *extapi.JSON) (sotoonDNSProviderConfig, error) { + cfg := sotoonDNSProviderConfig{} // handle the 'base case' where no configuration has been provided if cfgJSON == nil { return cfg, nil diff --git a/main_test.go b/main_test.go index 1d7d5ff..1b50ae4 100644 --- a/main_test.go +++ b/main_test.go @@ -5,8 +5,6 @@ import ( "testing" acmetest "github.com/cert-manager/cert-manager/test/acme" - - "github.com/cert-manager/webhook-example/example" ) var ( @@ -17,22 +15,12 @@ func TestRunsSuite(t *testing.T) { // The manifest path should contain a file named config.json that is a // snippet of valid configuration that should be included on the // ChallengeRequest passed as part of the test cases. - // - - // Uncomment the below fixture when implementing your custom DNS provider - //fixture := acmetest.NewFixture(&customDNSProviderSolver{}, - // 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), + fixture := acmetest.NewFixture(&sotoonDNSProviderSolver{}, + acmetest.SetResolvedZone(zone), + acmetest.SetAllowAmbientCredentials(false), + acmetest.SetManifestPath("testdata/sotoon-solver"), ) + //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.RunBasic(t) diff --git a/testdata/my-custom-solver/config.json b/testdata/my-custom-solver/config.json deleted file mode 100644 index 0967ef4..0000000 --- a/testdata/my-custom-solver/config.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/testdata/my-custom-solver/README.md b/testdata/sotoon-solver/README.md similarity index 100% rename from testdata/my-custom-solver/README.md rename to testdata/sotoon-solver/README.md diff --git a/testdata/sotoon-solver/config.json b/testdata/sotoon-solver/config.json new file mode 100644 index 0000000..1e21685 --- /dev/null +++ b/testdata/sotoon-solver/config.json @@ -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" +} \ No newline at end of file