From 5639782790c6df08c29336a79807f86271e73806 Mon Sep 17 00:00:00 2001 From: Ali Orouji Date: Wed, 17 Feb 2021 23:54:53 +0330 Subject: [PATCH] make conformance test pass --- .gitignore | 4 +- Makefile | 1 + README.md | 129 +++++++++--- main.go | 186 +++++++++++++++--- main_test.go | 3 +- scripts/fetch-test-binaries.sh | 24 +++ testdata/my-custom-solver/config.json | 1 - .../{my-custom-solver => sotoon}/README.md | 0 testdata/sotoon/config.json | 8 + testdata/sotoon/sotoon-credentials.yaml | 7 + 10 files changed, 301 insertions(+), 62 deletions(-) delete mode 100644 testdata/my-custom-solver/config.json rename testdata/{my-custom-solver => sotoon}/README.md (100%) create mode 100644 testdata/sotoon/config.json create mode 100644 testdata/sotoon/sotoon-credentials.yaml diff --git a/.gitignore b/.gitignore index f08a1d2..5472f91 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,7 @@ .idea +_out + # Ignore the built binary -cert-manager-webhook-example +cert-manager-webhook-sotoon diff --git a/Makefile b/Makefile index bddb85d..74b8e00 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ OUT := $(shell pwd)/_out $(shell mkdir -p "$(OUT)") verify: + sh ./scripts/fetch-test-binaries.sh go test -v . build: diff --git a/README.md b/README.md index dbf5a42..fc7c7b9 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,106 @@ -# ACME webhook example +# SOTOON Webhook for Cert Manager -The ACME issuer type supports an optional 'webhook' solver, which can be used -to implement custom DNS01 challenge solving logic. +This is a webhook solver for [Sotoon Cloud](https://sotoon.ir). -This is useful if you need to use cert-manager with a DNS provider that is not -officially supported in cert-manager core. +## Prerequisites -## Why not in core? +* [cert-manager](https://github.com/jetstack/cert-manager) version 0.11.0 or higher (*tested with 0.12.0*): + - [Installing on Kubernetes](https://cert-manager.io/docs/installation/kubernetes/#installing-with-helm) -As the project & adoption has grown, there has been an influx of DNS provider -pull requests to our core codebase. As this number has grown, the test matrix -has become un-maintainable and so, it's not possible for us to certify that -providers work to a sufficient level. +## Installation -By creating this 'interface' between cert-manager and DNS providers, we allow -users to quickly iterate and test out new integrations, and then packaging -those up themselves as 'extensions' to cert-manager. +Choose a unique group name to identify your company or organization (for example `acme.mycompany.example`). -We can also then provide a standardised 'testing framework', or set of -conformance tests, which allow us to validate the a DNS provider works as -expected. +```bash +helm install ./deploy/cert-manager-webhook-sotoon \ + --set groupName='' +``` -## Creating your own webhook +If you customized the installation of cert-manager, you may need to also set the `certManager.namespace` and `certManager.serviceAccountName` values. -Webhook's themselves are deployed as Kubernetes API services, in order to allow -administrators to restrict access to webhooks with Kubernetes RBAC. +## Issuer -This is important, as otherwise it'd be possible for anyone with access to your -webhook to complete ACME challenge validations and obtain certificates. +1. [Get your API token from Sotoon Panel](https://ocean.sotoon.ir/bepa/profile). The user whose api token is used must have `dns-editor` role: -To make the set up of these webhook's easier, we provide a template repository -that can be used to get started quickly. +2. Create a secret to store your api token secret: -### Creating your own repository + ```bash + kubectl create secret generic sotoon-credentials \ + --from-literal=apiToken='' + ``` -### Running the test suite +3. Grant permission to get the secret to the `cert-manager-webhook-sotoon` service account: + + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: cert-manager-webhook-sotoon:secret-reader + rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["sotoon-credentials"] + verbs: ["get", "watch"] + --- + apiVersion: rbac.authorization.k8s.io/v1beta1 + kind: RoleBinding + metadata: + name: cert-manager-webhook-sotoon:secret-reader + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: cert-manager-webhook-sotoon:secret-reader + subjects: + - apiGroup: "" + kind: ServiceAccount + name: cert-manager-webhook-sotoon + ``` + +4. Create a certificate issuer: + + ```yaml + apiVersion: cert-manager.io/v1alpha2 + kind: Issuer + metadata: + name: letsencrypt + spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: '' + privateKeySecretRef: + name: letsencrypt-account-key + solvers: + - dns01: + webhook: + groupName: '' + solverName: sotoon + config: + endpoint: https://api.sotoon.ir + namespace: + apiTokenSecretRef: + name: sotoon-credentials + key: apiToken + ``` + +## Certificate + +Issue a certificate: + +```yaml +apiVersion: cert-manager.io/v1alpha2 +kind: Certificate +metadata: + name: example-com +spec: + dnsNames: + - example.com + - *.example.com + issuerRef: + name: letsencrypt + secretName: example-com-tls +``` + +## Development All DNS providers **must** run the DNS01 provider conformance testing suite, else they will have undetermined behaviour when used with cert-manager. @@ -44,11 +110,16 @@ DNS01 webhook.** An example Go test file has been provided in [main_test.go](). -You can run the test suite with: +Before you can run the test suite, you need to download the test binaries: ```bash -$ TEST_ZONE_NAME=example.com go test . +./scripts/fetch-test-binaries.sh ``` -The example file has a number of areas you must fill in and replace with your -own options in order for tests to pass. +Then duplicate the `.sample` files in `testdata/sotoon/` and update the configuration with the appropriate SOTOON credentials. + +Now you can run the test suite with: + +```bash +TEST_ZONE_NAME=example.com. go test -v . +``` diff --git a/main.go b/main.go index fc04a75..ab65b79 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -26,12 +27,16 @@ var GroupName = os.Getenv("GROUP_NAME") var validate *validator.Validate +func init() { + validate = validator.New() +} + func main() { if GroupName == "" { panic("GROUP_NAME must be specified") } - validate = validator.New() + fmt.Println("salaaaam") // This will register our custom DNS provider with the webhook serving // library, making it available as an API under the provided GroupName. @@ -77,7 +82,6 @@ type sotoonDNSProviderConfig struct { // These fields will be set by users in the // `issuer.spec.acme.dns01.providers.webhook.config` field. - Email string `json:"email" validate:"email"` Endpoint string `json:"endpoint" validate:"url"` Namespace string `json:"namespace" validate:"hostname_rfc1123"` APITokenSecretRef corev1.SecretKeySelector `json:"apiTokenSecretRef"` @@ -104,12 +108,16 @@ func (c *sotoonDNSProviderSolver) secret(ref corev1.SecretKeySelector, namespace return string(bytes), nil } -func (c *sotoonDNSProviderSolver) sotoonClient(apiEndpoint, apiToken string) (*rest.RESTClient, error) { +func (c *sotoonDNSProviderSolver) sotoonClient(ch *v1alpha1.ChallengeRequest, cfg *sotoonDNSProviderConfig) (*rest.RESTClient, error) { + apiToken, err := c.secret(cfg.APITokenSecretRef, ch.ResourceNamespace) + if err != nil { + return nil, err + } v1beta1.AddToScheme(scheme.Scheme) restConfig := &rest.Config{} - restConfig.Host = apiEndpoint + restConfig.Host = cfg.Endpoint restConfig.APIPath = "/apis" restConfig.BearerToken = apiToken restConfig.ContentConfig.GroupVersion = &v1beta1.GroupVersion @@ -129,6 +137,97 @@ func (c *sotoonDNSProviderSolver) Name() string { return "sotoon" } +func getRelevantZones(sotoonClient *rest.RESTClient, namespace, origin string) (*v1beta1.DomainZoneList, error) { + dzl := &v1beta1.DomainZoneList{} + + if err := sotoonClient. + Get(). + Namespace(namespace). + Resource("domainzones"). + VersionedParams(&metav1.ListOptions{LabelSelector: fmt.Sprintf("dns.ravh.ir/origin=%s", origin)}, scheme.ParameterCodec). + Do(context.TODO()). + Into(dzl); err != nil { + return nil, err + } + + return dzl, nil +} + +func addTXTRecord(sotoonClient *rest.RESTClient, zone *v1beta1.DomainZone, subdomain, target string) error { + if zone.Status.Status == "OK" { + if zone.Spec.Records == nil { + zone.Spec.Records = make(v1beta1.RecordsMap) + } + + records := zone.Spec.Records[subdomain] + if records == nil { + records = v1beta1.RecordList{} + } + + for _, r := range records { + if r.TXT == target { + return nil + } + } + + records = append(records, v1beta1.Record{ + SpecifiedType: "TXT", + TXT: target, + TTL: 30, + }) + + zone.Spec.Records[subdomain] = records + + zoneData, err := json.Marshal(zone) + if err != nil { + return err + } + + if err := sotoonClient.Put().Name(zone.Name).Namespace(zone.Namespace).Resource("domainzones").Body(zoneData).Do(context.TODO()).Into(zone); err != nil { + return err + } + } + + return nil +} + +func removeTXTRecord(sotoonClient *rest.RESTClient, zone *v1beta1.DomainZone, subdomain, target string) error { + if zone.Status.Status == "OK" { + if zone.Spec.Records == nil { + return nil + } + + records := zone.Spec.Records[subdomain] + if records == nil { + return nil + } + + var newRecords v1beta1.RecordList + for _, r := range records { + if r.TXT != target { + newRecords = append(newRecords, r) + } + } + + if len(newRecords) == len(records) { + return nil + } + + zone.Spec.Records[subdomain] = newRecords + + zoneData, err := json.Marshal(zone) + if err != nil { + return err + } + + if err := sotoonClient.Put().Name(zone.Name).Namespace(zone.Namespace).Resource("domainzones").Body(zoneData).Do(context.TODO()).Into(zone); err != nil { + return err + } + } + + return nil +} + // Present is responsible for actually presenting the DNS record with the // DNS provider. // This method should tolerate being called multiple times with the same value. @@ -140,37 +239,26 @@ func (c *sotoonDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { return err } - if err := cfg.validate(); err != nil { - return err - } - - apiToken, err := c.secret(cfg.APITokenSecretRef, ch.ResourceNamespace) - if err != nil { - return err - } - - scl, err := c.sotoonClient(cfg.Endpoint, apiToken) + sotoonClient, err := c.sotoonClient(ch, cfg) if err != nil { return err } origin := util.UnFqdn(ch.ResolvedZone) + zones, err := getRelevantZones(sotoonClient, cfg.Namespace, origin) + if err != nil { + return err + } - dzs := v1beta1.DomainZoneList{} - scl. - Get(). - Namespace(cfg.Namespace). - Resource("domainzones"). - VersionedParams(&metav1.ListOptions{LabelSelector: fmt.Sprintf("dns.ravh.ir/origin=%s", origin)}, scheme.ParameterCodec). - Do(context.TODO()). - Into(&dzs) + subdomain := getSubDomain(origin, ch.ResolvedFQDN) + target := ch.Key - fmt.Println(dzs) + for _, zone := range zones.Items { + if err := addTXTRecord(sotoonClient, &zone, subdomain, target); err != nil { + return err + } + } - // TODO: do something more useful with the decoded configuration - fmt.Printf("Decoded configuration %v", cfg) - - // TODO: add code that sets a record in the DNS provider's console return nil } @@ -181,6 +269,31 @@ func (c *sotoonDNSProviderSolver) Present(ch *v1alpha1.ChallengeRequest) error { // This is in order to facilitate multiple DNS validations for the same domain // concurrently. func (c *sotoonDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { + cfg, err := loadConfig(ch.Config) + if err != nil { + return err + } + + sotoonClient, err := c.sotoonClient(ch, cfg) + if err != nil { + return err + } + + origin := util.UnFqdn(ch.ResolvedZone) + zones, err := getRelevantZones(sotoonClient, cfg.Namespace, origin) + if err != nil { + return err + } + + subdomain := getSubDomain(origin, ch.ResolvedFQDN) + target := ch.Key + + for _, zone := range zones.Items { + if err := removeTXTRecord(sotoonClient, &zone, subdomain, target); err != nil { + return err + } + } + // TODO: add code that deletes a record from the DNS provider's console return nil } @@ -207,16 +320,29 @@ func (c *sotoonDNSProviderSolver) Initialize(kubeClientConfig *rest.Config, stop // loadConfig is a small helper function that decodes JSON configuration into // the typed config struct. -func loadConfig(cfgJSON *extapi.JSON) (sotoonDNSProviderConfig, error) { - cfg := sotoonDNSProviderConfig{} +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 } - if err := json.Unmarshal(cfgJSON.Raw, &cfg); err != nil { + if err := json.Unmarshal(cfgJSON.Raw, cfg); err != nil { return cfg, fmt.Errorf("error decoding solver config: %v", err) } + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil } + +// utils +func getSubDomain(domain, fqdn string) string { + if idx := strings.Index(fqdn, "."+domain); idx != -1 { + return fqdn[:idx] + } + + return util.UnFqdn(fqdn) +} diff --git a/main_test.go b/main_test.go index f8fc6a2..88a801e 100644 --- a/main_test.go +++ b/main_test.go @@ -17,9 +17,10 @@ func TestRunsSuite(t *testing.T) { // ChallengeRequest passed as part of the test cases. fixture := dns.NewFixture(&sotoonDNSProviderSolver{}, + dns.SetBinariesPath("_out/kubebuilder/bin"), dns.SetResolvedZone(zone), dns.SetAllowAmbientCredentials(false), - dns.SetManifestPath("testdata/my-custom-solver"), + dns.SetManifestPath("testdata/sotoon"), ) fixture.RunConformance(t) diff --git a/scripts/fetch-test-binaries.sh b/scripts/fetch-test-binaries.sh index f1f641a..c1a66c6 100755 --- a/scripts/fetch-test-binaries.sh +++ b/scripts/fetch-test-binaries.sh @@ -1 +1,25 @@ #!/usr/bin/env bash + +set -e + +k8s_version=1.14.1 +arch=amd64 + +if [[ "$OSTYPE" == "linux-gnu" ]]; then + os="linux" +elif [[ "$OSTYPE" == "darwin"* ]]; then + os="darwin" +else + echo "OS '$OSTYPE' not supported." >&2 + exit 1 +fi + +root=$(cd "`dirname $0`"/..; pwd) +output_dir="$root"/_out +archive_name="kubebuilder-tools-$k8s_version-$os-$arch.tar.gz" +archive_file="$output_dir/$archive_name" +archive_url="https://storage.googleapis.com/kubebuilder-tools/$archive_name" + +mkdir -p "$output_dir" +curl -sL "$archive_url" -o "$archive_file" +tar -zxf "$archive_file" -C "$output_dir/" \ No newline at end of file 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/README.md similarity index 100% rename from testdata/my-custom-solver/README.md rename to testdata/sotoon/README.md diff --git a/testdata/sotoon/config.json b/testdata/sotoon/config.json new file mode 100644 index 0000000..b970c5d --- /dev/null +++ b/testdata/sotoon/config.json @@ -0,0 +1,8 @@ +{ + "endpoint": "", + "namespace": "", + "apiTokenSecretRef": { + "Name": "sotoon-credentials", + "Key": "apiToken" + } +} diff --git a/testdata/sotoon/sotoon-credentials.yaml b/testdata/sotoon/sotoon-credentials.yaml new file mode 100644 index 0000000..9906cab --- /dev/null +++ b/testdata/sotoon/sotoon-credentials.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: sotoon-credentials +type: Opaque +data: + apiToken: \ No newline at end of file