make conformance test pass

This commit is contained in:
Ali Orouji 2021-02-17 23:54:53 +03:30
parent 1798a826d4
commit 5639782790
10 changed files with 301 additions and 62 deletions

4
.gitignore vendored
View file

@ -13,5 +13,7 @@
.idea .idea
_out
# Ignore the built binary # Ignore the built binary
cert-manager-webhook-example cert-manager-webhook-sotoon

View file

@ -6,6 +6,7 @@ OUT := $(shell pwd)/_out
$(shell mkdir -p "$(OUT)") $(shell mkdir -p "$(OUT)")
verify: verify:
sh ./scripts/fetch-test-binaries.sh
go test -v . go test -v .
build: build:

129
README.md
View file

@ -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 This is a webhook solver for [Sotoon Cloud](https://sotoon.ir).
to implement custom DNS01 challenge solving logic.
This is useful if you need to use cert-manager with a DNS provider that is not ## Prerequisites
officially supported in cert-manager core.
## 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 ## Installation
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.
By creating this 'interface' between cert-manager and DNS providers, we allow Choose a unique group name to identify your company or organization (for example `acme.mycompany.example`).
users to quickly iterate and test out new integrations, and then packaging
those up themselves as 'extensions' to cert-manager.
We can also then provide a standardised 'testing framework', or set of ```bash
conformance tests, which allow us to validate the a DNS provider works as helm install ./deploy/cert-manager-webhook-sotoon \
expected. --set groupName='<YOUR_UNIQUE_GROUP_NAME>'
```
## 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 ## Issuer
administrators to restrict access to webhooks with Kubernetes RBAC.
This is important, as otherwise it'd be possible for anyone with access to your 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:
webhook to complete ACME challenge validations and obtain certificates.
To make the set up of these webhook's easier, we provide a template repository 2. Create a secret to store your api token secret:
that can be used to get started quickly.
### Creating your own repository ```bash
kubectl create secret generic sotoon-credentials \
--from-literal=apiToken='<SOTOON_API_TOKEN>'
```
### 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: '<YOUR_EMAIL_ADDRESS>'
privateKeySecretRef:
name: letsencrypt-account-key
solvers:
- dns01:
webhook:
groupName: '<YOUR_UNIQUE_GROUP_NAME>'
solverName: sotoon
config:
endpoint: https://api.sotoon.ir
namespace: <SOTOON_NAMESPACE_OF_YOURS>
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, All DNS providers **must** run the DNS01 provider conformance testing suite,
else they will have undetermined behaviour when used with cert-manager. 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](). 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 ```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 Then duplicate the `.sample` files in `testdata/sotoon/` and update the configuration with the appropriate SOTOON credentials.
own options in order for tests to pass.
Now you can run the test suite with:
```bash
TEST_ZONE_NAME=example.com. go test -v .
```

186
main.go
View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" extapi "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
"k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
@ -26,12 +27,16 @@ var GroupName = os.Getenv("GROUP_NAME")
var validate *validator.Validate var validate *validator.Validate
func init() {
validate = validator.New()
}
func main() { func main() {
if GroupName == "" { if GroupName == "" {
panic("GROUP_NAME must be specified") panic("GROUP_NAME must be specified")
} }
validate = validator.New() fmt.Println("salaaaam")
// This will register our custom DNS provider with the webhook serving // This will register our custom DNS provider with the webhook serving
// library, making it available as an API under the provided GroupName. // 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 // 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" validate:"email"`
Endpoint string `json:"endpoint" validate:"url"` Endpoint string `json:"endpoint" validate:"url"`
Namespace string `json:"namespace" validate:"hostname_rfc1123"` Namespace string `json:"namespace" validate:"hostname_rfc1123"`
APITokenSecretRef corev1.SecretKeySelector `json:"apiTokenSecretRef"` APITokenSecretRef corev1.SecretKeySelector `json:"apiTokenSecretRef"`
@ -104,12 +108,16 @@ func (c *sotoonDNSProviderSolver) secret(ref corev1.SecretKeySelector, namespace
return string(bytes), nil 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) v1beta1.AddToScheme(scheme.Scheme)
restConfig := &rest.Config{} restConfig := &rest.Config{}
restConfig.Host = apiEndpoint restConfig.Host = cfg.Endpoint
restConfig.APIPath = "/apis" restConfig.APIPath = "/apis"
restConfig.BearerToken = apiToken restConfig.BearerToken = apiToken
restConfig.ContentConfig.GroupVersion = &v1beta1.GroupVersion restConfig.ContentConfig.GroupVersion = &v1beta1.GroupVersion
@ -129,6 +137,97 @@ func (c *sotoonDNSProviderSolver) Name() string {
return "sotoon" 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 // Present is responsible for actually presenting the DNS record with the
// DNS provider. // DNS provider.
// This method should tolerate being called multiple times with the same value. // 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 return err
} }
if err := cfg.validate(); err != nil { sotoonClient, err := c.sotoonClient(ch, cfg)
return err
}
apiToken, err := c.secret(cfg.APITokenSecretRef, ch.ResourceNamespace)
if err != nil {
return err
}
scl, err := c.sotoonClient(cfg.Endpoint, apiToken)
if err != nil { if err != nil {
return err return err
} }
origin := util.UnFqdn(ch.ResolvedZone) origin := util.UnFqdn(ch.ResolvedZone)
zones, err := getRelevantZones(sotoonClient, cfg.Namespace, origin)
if err != nil {
return err
}
dzs := v1beta1.DomainZoneList{} subdomain := getSubDomain(origin, ch.ResolvedFQDN)
scl. target := ch.Key
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)
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 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 // This is in order to facilitate multiple DNS validations for the same domain
// concurrently. // concurrently.
func (c *sotoonDNSProviderSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { 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 // TODO: add code that deletes a record from the DNS provider's console
return nil 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 // loadConfig is a small helper function that decodes JSON configuration into
// the typed config struct. // the typed config struct.
func loadConfig(cfgJSON *extapi.JSON) (sotoonDNSProviderConfig, error) { func loadConfig(cfgJSON *extapi.JSON) (*sotoonDNSProviderConfig, error) {
cfg := sotoonDNSProviderConfig{} 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
} }
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) return cfg, fmt.Errorf("error decoding solver config: %v", err)
} }
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil 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)
}

View file

@ -17,9 +17,10 @@ func TestRunsSuite(t *testing.T) {
// ChallengeRequest passed as part of the test cases. // ChallengeRequest passed as part of the test cases.
fixture := dns.NewFixture(&sotoonDNSProviderSolver{}, fixture := dns.NewFixture(&sotoonDNSProviderSolver{},
dns.SetBinariesPath("_out/kubebuilder/bin"),
dns.SetResolvedZone(zone), dns.SetResolvedZone(zone),
dns.SetAllowAmbientCredentials(false), dns.SetAllowAmbientCredentials(false),
dns.SetManifestPath("testdata/my-custom-solver"), dns.SetManifestPath("testdata/sotoon"),
) )
fixture.RunConformance(t) fixture.RunConformance(t)

View file

@ -1 +1,25 @@
#!/usr/bin/env bash #!/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/"

View file

@ -1 +0,0 @@
{}

8
testdata/sotoon/config.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"endpoint": "<sotoon-api-url>",
"namespace": "<sotoon-namespace-of-yours>",
"apiTokenSecretRef": {
"Name": "sotoon-credentials",
"Key": "apiToken"
}
}

View file

@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: sotoon-credentials
type: Opaque
data:
apiToken: <sotoon-api-token>