mirror of
https://github.com/cert-manager/webhook-example.git
synced 2025-07-01 22:35:49 +02:00
make conformance test pass
This commit is contained in:
parent
1798a826d4
commit
5639782790
10 changed files with 301 additions and 62 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -13,5 +13,7 @@
|
|||
|
||||
.idea
|
||||
|
||||
_out
|
||||
|
||||
# Ignore the built binary
|
||||
cert-manager-webhook-example
|
||||
cert-manager-webhook-sotoon
|
||||
|
|
1
Makefile
1
Makefile
|
@ -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
129
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='<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
186
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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/"
|
1
testdata/my-custom-solver/config.json
vendored
1
testdata/my-custom-solver/config.json
vendored
|
@ -1 +0,0 @@
|
|||
{}
|
8
testdata/sotoon/config.json
vendored
Normal file
8
testdata/sotoon/config.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"endpoint": "<sotoon-api-url>",
|
||||
"namespace": "<sotoon-namespace-of-yours>",
|
||||
"apiTokenSecretRef": {
|
||||
"Name": "sotoon-credentials",
|
||||
"Key": "apiToken"
|
||||
}
|
||||
}
|
7
testdata/sotoon/sotoon-credentials.yaml
vendored
Normal file
7
testdata/sotoon/sotoon-credentials.yaml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: sotoon-credentials
|
||||
type: Opaque
|
||||
data:
|
||||
apiToken: <sotoon-api-token>
|
Loading…
Reference in a new issue