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
_out
# 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)")
verify:
sh ./scripts/fetch-test-binaries.sh
go test -v .
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
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='<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
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='<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,
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 .
```

186
main.go
View file

@ -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)
}

View file

@ -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)

View file

@ -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/"

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>